fix(util-time): bound a span, and state the conversions as they are
- Duration rejects a count outside the exactly representable millisecond range, which is what makes decompose's whole-part identity exact; the postcondition is asserted over a sweep and a random sample - the to* JSDoc no longer claims a round trip restores the count: each is one division, so it answers the nearest double - a test rules out a Symbol.toPrimitive coercion hook, the README names both implementation modules, and the note records that the value type ships its full operation set before its second consumer exists
This commit is contained in:
1 parent
994b9e07bf
commit
0b5803cf0c
8 files changed
+90
-36
No files matched your search
@@ -9,11 +9,11 @@
|
||||
en: abd0263d14861926
|
||||
zh: e1736cd08e87f795
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
|
||||
en: 34feee585cb33001
|
||||
zh: d882bbba56a679bd
|
||||
en: 4130cbbd30e06857
|
||||
zh: 85f9153cf5ad44ce
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
|
||||
en: 1737484c099fab52
|
||||
zh: 303b144c52d167bd
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
|
||||
en: 7e3004318640cca7
|
||||
zh: 007d7fe761718d37
|
||||
en: f7181f87764118d2
|
||||
zh: 0af236ff4b6a935b
|
||||
@@ -10,7 +10,7 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds it, `src/index.ts` re-exports it and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
|
||||
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
|
||||
|
||||
The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, and `LocalCalendarTime.year` states which of them rejects year 0 (`parseLocalCalendar`) and which accepts it (`resolveLocalInstant`, whose callers derive local dates at the UTC edges).
|
||||
|
||||
@@ -18,7 +18,7 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d
|
||||
|
||||
`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers.
|
||||
|
||||
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen; the count is readable only as `milliseconds` or through a fractional `toSeconds`/`toMinutes`/`toHours`/`toDays` conversion, and there is deliberately no `valueOf` and no `toString`, so no reader ever receives a bare count whose unit it has to remember. Addition, subtraction, ordering, and a whole-part `decompose()` are the whole operation set.
|
||||
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen; the count is readable only as `milliseconds` or through a fractional `toSeconds`/`toMinutes`/`toHours`/`toDays` conversion, and there is deliberately no `valueOf` and no `toString`, so no reader ever receives a bare count whose unit it has to remember. Addition, subtraction, ordering, a negative-span test, and a whole-part `decompose()` are the whole operation set, alongside a `zero` identity and a `fromSeconds` constructor for a boundary whose unit is seconds.
|
||||
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s README and description lose the `zero-dependency` claim the added dependency contradicts.
|
||||
|
||||
@@ -44,6 +44,8 @@ The time profile now has one home. A change to the accepted instant profile or t
|
||||
|
||||
The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
|
||||
|
||||
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, verified by a differential over more than eight thousand inputs including negatives, fractions, and values past a year. One input does change: a non-finite elapsed sample now throws from the span constructor instead of rendering `NaNs`, which the package's own reading invariant already refused, so the failure moves to the sample that produced it.
|
||||
The value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` are called in production today, by time-context; `fromSeconds`, `milliseconds`, `plus`, `minus`, `compare`, `isNegative`, `zero`, and the four `to*` conversions are published without a caller. That is a deliberate choice rather than an oversight: the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the intended next consumers, and the alternative — a class that holds a span but can only split it — would have to grow the same members one call site at a time. The package README records the un-migrated boundaries as a known limitation.
|
||||
|
||||
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured with a differential over more than eight thousand inputs including negatives, fractions, and values past a year; that differential was a review probe, not a committed test, and the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). One input does change: a non-finite elapsed sample now throws from the span constructor instead of rendering `NaNs`, which the package's own reading invariant already refused, so the failure moves to the sample that produced it.
|
||||
|
||||
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format its invariant validates. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch.
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` 拥有各边界所接收的时间词汇。它位于 `packages/util/time/src/instant.ts`,`src/index.ts` 转出它,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
|
||||
`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
|
||||
|
||||
本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,而 `LocalCalendarTime.year` 说明了二者中谁拒绝年份 0(`parseLocalCalendar`)、谁接受它(`resolveLocalInstant`,其调用方需要在 UTC 边界推导本地日期)。
|
||||
|
||||
@@ -18,7 +18,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。
|
||||
|
||||
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不提供 `valueOf` 与 `toString`,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序与整数量拆解 `decompose()` 就是全部操作集合。
|
||||
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不提供 `valueOf` 与 `toString`,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序、负值判断与整数量拆解 `decompose()` 就是全部操作集合,另有 `zero` 恒等元与面向以秒为单位边界的 `fromSeconds` 构造入口。
|
||||
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。
|
||||
|
||||
@@ -44,6 +44,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
|
||||
|
||||
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,已用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分验证。有一个输入会变:非有限的耗时样本现在由时间段构造函数抛出,而不是渲染出 `NaNs`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。
|
||||
该值类型在第二个消费方出现之前就交付了完整操作集合。目前生产代码只调用 `fromMilliseconds` 与 `decompose()`(由 time-context 调用);`fromSeconds`、`milliseconds`、`plus`、`minus`、`compare`、`isNegative`、`zero` 与四个 `to*` 换算都没有调用方。这是有意的选择而不是疏漏:`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段是预期的下一批消费方,而另一种做法——一个能保存时间段却只能拆解它的类——会在每个调用点出现时再长出同样的成员。包 README 把这些尚未迁移的边界记为已知限制。
|
||||
|
||||
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得;该差分是评审用的探针而非提交进仓库的测试,消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有一个输入会变:非有限的耗时样本现在由时间段构造函数抛出,而不是渲染出 `NaNs`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。
|
||||
|
||||
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有其不变量所校验的持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
|
||||
@@ -21,11 +21,11 @@
|
||||
en: 7015b3ed33de4c53
|
||||
zh: d4fc9aee6f6910ca
|
||||
/dsh-util-time/use-this-package/entry-point:
|
||||
en: 306f0f5d65af8223
|
||||
zh: a79b60c39be361c8
|
||||
en: 88668c3dd1bdb75b
|
||||
zh: fb48d941ae433335
|
||||
/dsh-util-time/understand-the-implementation:
|
||||
en: f42cb0fc332d7740
|
||||
zh: f980bcae2f72fdd8
|
||||
en: ef1369f1e986acf5
|
||||
zh: b7a98e3d79048d50
|
||||
/dsh-util-time/further-exploration:
|
||||
en: 8aa95ebdfd1f323e
|
||||
zh: c6879eac9fa6e087
|
||||
@@ -36,8 +36,8 @@
|
||||
en: daf6e45a18c087b0
|
||||
zh: e6714f1e65ed55ce
|
||||
/dsh-util-time/known-limitations-and-deferred-work:
|
||||
en: b4108682b9b18245
|
||||
zh: 7dfb014c2dc1775f
|
||||
en: 20e184351878f41b
|
||||
zh: 1c94d8a715df7c1b
|
||||
/dsh-util-time/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -50,7 +50,7 @@ Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds
|
||||
Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count
|
||||
```
|
||||
|
||||
`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap. `Duration` is a frozen value type over one millisecond count: it validates that the count is finite, adds and orders without any unit conversion, converts to seconds, minutes, hours, and days fractionally, and splits a non-negative span into whole parts.
|
||||
`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap. `Duration` is a frozen value type over one millisecond count: it validates that the count is finite and inside the exactly representable range, adds, subtracts, orders, and reports a negative span without any unit conversion, converts to seconds, minutes, hours, and days fractionally, and splits a non-negative span into whole parts.
|
||||
|
||||
-----
|
||||
|
||||
@@ -60,7 +60,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`src/instant.ts` holds the whole implementation; `src/index.ts` re-exports it and adds `canonicalClientTimeZone`, the `string | undefined` form the wire boundaries already consume. The module JSDoc there states each entry's accepted profile and every reason.
|
||||
`src/instant.ts` holds the parsers and `src/duration.ts` the span; `src/index.ts` re-exports both and adds `canonicalClientTimeZone`, the `string | undefined` form the wire boundaries already consume. The module JSDoc there states each entry's accepted profile and every reason.
|
||||
|
||||
The strict parsers match a fixed regular expression before constructing any date, so a malformed spelling, an out-of-range clock field, and a date the calendar does not have answer distinct reasons. Local resolution defers to `@js-temporal/polyfill`: `disambiguation: 'earlier'` picks the first instant of an overlap, and the local-field round trip answers `undefined` for a value inside a forward transition's gap.
|
||||
|
||||
@@ -106,7 +106,8 @@ None of its own. The consumer that injects a zone- or schedule-derived line into
|
||||
- **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it.
|
||||
- **No recurrence arithmetic** — instant validity, local-calendar parsing, zone resolution, canonical formatting, and a millisecond span only; a caller that walks candidate dates or intervals owns that search.
|
||||
- **`Duration` has no calendar units** — a day is exactly 24 hours and a month has no fixed length, so a caller that needs local calendar days derives them from the zone.
|
||||
- **`Duration` does not convert units implicitly** — it has no `valueOf` and no `toString`, so a reader states its unit through `milliseconds` or a `to*` conversion.
|
||||
- **`Duration` does not convert units implicitly** — it has no `valueOf`, no `toString`, and no `Symbol.toPrimitive`, so a reader states its unit through `milliseconds` or a `to*` conversion.
|
||||
- **Seconds-unit boundaries still multiply** — `dsh-schedule` and the DeepSeek file store convert their own second-valued fields with `* 1_000`; `Duration.fromSeconds` is the intended next consumer for those spans, and none of them has moved yet.
|
||||
- **The `Temporal` polyfill is a runtime dependency** — a consumer that imports only `canonicalClientTimeZone` still resolves it at module load, because both entries share one module.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -50,7 +50,7 @@ Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds
|
||||
Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count
|
||||
```
|
||||
|
||||
不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限,加减与排序都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。
|
||||
不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,加减、排序与负值判断都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。
|
||||
|
||||
-----
|
||||
|
||||
@@ -60,7 +60,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and
|
||||
<details>
|
||||
<summary>实现内部细节 —— 点击展开</summary>
|
||||
|
||||
`src/instant.ts` 承载全部实现;`src/index.ts` 转出它,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。
|
||||
`src/instant.ts` 承载各解析器,`src/duration.ts` 承载时间段;`src/index.ts` 转出两者,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。
|
||||
|
||||
严格解析器先匹配固定正则,再构造任何日期,因此拼写错误、越界时钟字段与日历上不存在的日期分别回答不同的原因。本地解析交给 `@js-temporal/polyfill`:`disambiguation: 'earlier'` 取重叠时段中的第一个时刻,而本地字段往返比对对前向跳变缺口内的值回答 `undefined`。
|
||||
|
||||
@@ -106,7 +106,8 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and
|
||||
- **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。
|
||||
- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。
|
||||
- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。
|
||||
- **`Duration` 不做隐式单位换算**——它没有 `valueOf` 也没有 `toString`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。
|
||||
- **`Duration` 不做隐式单位换算**——它没有 `valueOf`、`toString` 与 `Symbol.toPrimitive`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。
|
||||
- **秒为单位的边界仍在自行相乘**——`dsh-schedule` 与 DeepSeek 的文件存储用 `* 1_000` 换算各自以秒为单位的字段;`Duration.fromSeconds` 是这些时间段的预期下一个消费方,目前都还没改。
|
||||
- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -32,12 +32,23 @@ export interface DurationParts {
|
||||
readonly milliseconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest millisecond count a span holds.
|
||||
*
|
||||
* Every integer up to this bound is exactly representable, which is what makes
|
||||
* the whole-part split exact; beyond it the parts a reader gets back would not
|
||||
* add up to the span.
|
||||
*/
|
||||
const MAX_SPAN_MS = Number.MAX_SAFE_INTEGER
|
||||
|
||||
/**
|
||||
* One span of time in milliseconds.
|
||||
*
|
||||
* Instances are frozen, so a span cannot drift after it is built, and they carry
|
||||
* no calendar meaning: a month is not a fixed count of milliseconds, so no entry
|
||||
* here converts to or from calendar units.
|
||||
* Instances are frozen, so a span cannot drift after it is built. A span is
|
||||
* bounded by the exactly representable millisecond range, which is wider than
|
||||
* any interval this harness schedules, and it carries no calendar meaning: a
|
||||
* month is not a fixed count of milliseconds, so no entry here converts to or
|
||||
* from calendar units.
|
||||
*/
|
||||
export class Duration {
|
||||
/** The additive identity. */
|
||||
@@ -52,14 +63,19 @@ export class Duration {
|
||||
|
||||
/**
|
||||
* Build one span from a millisecond count.
|
||||
* @param milliseconds - Finite millisecond count; a fractional count is kept as given.
|
||||
* @param milliseconds - Count within the exactly representable range (about 285,000 years); fractions are kept.
|
||||
* @returns The span.
|
||||
* @throws TypeError when the count is not a finite number.
|
||||
* @throws TypeError when the count is not finite, or exceeds the exactly representable millisecond range.
|
||||
*/
|
||||
static fromMilliseconds(milliseconds: number): Duration {
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`)
|
||||
}
|
||||
if (Math.abs(milliseconds) > MAX_SPAN_MS) {
|
||||
throw new TypeError(
|
||||
`duration milliseconds must not exceed ${MAX_SPAN_MS} so its parts stay exact, got ${String(milliseconds)}`,
|
||||
)
|
||||
}
|
||||
return new Duration(milliseconds)
|
||||
}
|
||||
|
||||
@@ -120,7 +136,7 @@ export class Duration {
|
||||
|
||||
/**
|
||||
* The span in seconds.
|
||||
* @returns The span as a fractional second count, lossless in both directions.
|
||||
* @returns The span as a fractional seconds count: one division, so the answer is the double nearest the exact ratio.
|
||||
*/
|
||||
toSeconds(): number {
|
||||
return this.span / MS_PER_SECOND
|
||||
@@ -128,7 +144,7 @@ export class Duration {
|
||||
|
||||
/**
|
||||
* The span in minutes.
|
||||
* @returns The span as a fractional minute count, lossless in both directions.
|
||||
* @returns The span as a fractional minutes count: one division, so the answer is the double nearest the exact ratio.
|
||||
*/
|
||||
toMinutes(): number {
|
||||
return this.span / MS_PER_MINUTE
|
||||
@@ -136,7 +152,7 @@ export class Duration {
|
||||
|
||||
/**
|
||||
* The span in hours.
|
||||
* @returns The span as a fractional hour count, lossless in both directions.
|
||||
* @returns The span as a fractional hours count: one division, so the answer is the double nearest the exact ratio.
|
||||
*/
|
||||
toHours(): number {
|
||||
return this.span / MS_PER_HOUR
|
||||
@@ -148,7 +164,7 @@ export class Duration {
|
||||
* A day here is exactly 24 hours. A wall-clock reading that crosses a
|
||||
* daylight-saving transition is not a whole number of days, so a caller that
|
||||
* needs local calendar days derives them from the zone instead.
|
||||
* @returns The span as a fractional day count, lossless in both directions.
|
||||
* @returns The span as a fractional days count: one division, so the answer is the double nearest the exact ratio.
|
||||
*/
|
||||
toDays(): number {
|
||||
return this.span / MS_PER_DAY
|
||||
@@ -156,8 +172,9 @@ export class Duration {
|
||||
|
||||
/**
|
||||
* Split the span into whole days, hours, minutes, seconds, and the remaining
|
||||
* fraction of a second. Each part is floored, so the parts never exceed the
|
||||
* span.
|
||||
* fraction of a second. Each part is floored, and every span this class accepts
|
||||
* is inside the exactly representable millisecond range, so the parts add back
|
||||
* up to the span and the remainder stays below one second.
|
||||
* @returns The frozen whole parts.
|
||||
* @throws RangeError when the span is negative, because a negative interval
|
||||
* has no single whole-part decomposition: the caller owns that policy.
|
||||
|
||||
@@ -25,14 +25,25 @@ describe('Duration construction', () => {
|
||||
expect(() => Duration.fromSeconds(1e306)).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('refuses a count above the exactly representable millisecond range', () => {
|
||||
expect(() => Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER + 1)).toThrow(TypeError)
|
||||
expect(Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).milliseconds).toBe(Number.MAX_SAFE_INTEGER)
|
||||
// The bound is what keeps the whole-part identity exact, so it is asserted, not assumed.
|
||||
const parts = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose()
|
||||
const summed = parts.days * 86_400_000 + parts.hours * 3_600_000
|
||||
+ parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds
|
||||
expect(summed).toBe(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
it('is frozen, and overrides no implicit conversion', () => {
|
||||
const span = Duration.fromMilliseconds(1)
|
||||
expect(Object.isFrozen(span)).toBe(true)
|
||||
expect(Reflect.set(span, 'span', 2)).toBe(false)
|
||||
expect(span.milliseconds).toBe(1)
|
||||
// Neither coercion is overridden, so no caller obtains the count by coercion.
|
||||
// No coercion hook is overridden, so no caller obtains the count by coercion.
|
||||
expect(Object.hasOwn(Duration.prototype, 'valueOf')).toBe(false)
|
||||
expect(Object.hasOwn(Duration.prototype, 'toString')).toBe(false)
|
||||
expect(Object.hasOwn(Duration.prototype, Symbol.toPrimitive)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,13 +67,18 @@ describe('Duration arithmetic', () => {
|
||||
})
|
||||
|
||||
describe('Duration conversion', () => {
|
||||
it('converts to larger units fractionally, so nothing is lost either way', () => {
|
||||
it('converts to larger units fractionally, by one division each', () => {
|
||||
const span = Duration.fromMilliseconds(90_061_500)
|
||||
expect(span.toSeconds()).toBe(90_061.5)
|
||||
expect(span.toMinutes()).toBe(1_501.025)
|
||||
expect(span.toHours()).toBe(25.017_083_333_333_332)
|
||||
expect(span.toDays()).toBe(1.042_378_472_222_222_2)
|
||||
expect(Duration.fromSeconds(span.toSeconds()).milliseconds).toBe(span.milliseconds)
|
||||
// A conversion is a single division, so a round trip returns the nearest
|
||||
// double rather than the original count: 1001 ms is 1.001 s, and 1.001 s is
|
||||
// not exactly 1001 ms.
|
||||
expect(Duration.fromMilliseconds(1_001).toSeconds()).toBe(1.001)
|
||||
expect(Duration.fromSeconds(1.001).milliseconds).toBe(1_000.999_999_999_999_9)
|
||||
})
|
||||
|
||||
it('covers a day as exactly twenty-four hours', () => {
|
||||
@@ -93,7 +109,22 @@ describe('Duration decompose', () => {
|
||||
const summed = parts.days * 86_400_000 + parts.hours * 3_600_000
|
||||
+ parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds
|
||||
expect(summed).toBe(3_661_999)
|
||||
expect(summed).toBeLessThanOrEqual(3_661_999)
|
||||
})
|
||||
|
||||
it('keeps the whole-part identity across the accepted range', () => {
|
||||
const sum = (parts: { days: number; hours: number; minutes: number; seconds: number; milliseconds: number }): number =>
|
||||
parts.days * 86_400_000 + parts.hours * 3_600_000
|
||||
+ parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds
|
||||
for (let span = 0; span <= 120_000; span += 1) {
|
||||
expect(sum(Duration.fromMilliseconds(span).decompose())).toBe(span)
|
||||
}
|
||||
for (let i = 0; i < 2_000; i += 1) {
|
||||
const span = Math.random() * Number.MAX_SAFE_INTEGER
|
||||
const parts = Duration.fromMilliseconds(span).decompose()
|
||||
expect(sum(parts)).toBe(span)
|
||||
expect(parts.milliseconds).toBeGreaterThanOrEqual(0)
|
||||
expect(parts.milliseconds).toBeLessThan(1_000)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a negative span instead of inventing a sign policy', () => {
|
||||
|
||||
Reference in new issue
Block a user