feat(util-time): add a millisecond Duration span

Duration holds one finite millisecond count behind a frozen class: a
class rather than a branded number because TypeScript erases a brand
through arithmetic, so only an instance can stop seconds and
milliseconds from being added. It adds, subtracts, orders, converts to
larger units fractionally, and splits a non-negative span into whole
days, hours, minutes, and seconds. There is no valueOf and no toString,
so a reader always states its unit.

dsh-time-context reads its elapsed line through it and keeps the
wording: a backward clock reading still clamps to zero there. The
rendered text is byte-identical for every finite sample, verified by a
differential over more than eight thousand inputs. A non-finite sample
now throws from the span constructor instead of rendering 'NaNs', which
the package's own reading invariant already refused.
This commit is contained in:
Chinesezjc committed 2026-09-25 14:48:01 +08:00
1 parent bbdac8febe
commit 0939392e71
14 files changed
+366 -47

No files matched your search

@@ -9,11 +9,11 @@
en: abd0263d14861926
zh: e1736cd08e87f795
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
en: d97690dac210f970
zh: b78439b9f402b278
en: 34feee585cb33001
zh: d882bbba56a679bd
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
en: 5bf3cc0d112531e7
zh: 99ed3e958d9d8ccb
en: 1737484c099fab52
zh: 303b144c52d167bd
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
en: 22e0e257ba361a33
zh: 599ad36dccf8bd64
en: 7e3004318640cca7
zh: 007d7fe761718d37
@@ -18,6 +18,8 @@ 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.
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.
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's other suites assert error codes only, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
@@ -30,6 +32,10 @@ Observable behavior does not change. Every accepted input resolves to the same e
**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication.
**Brand the millisecond count instead of building a class.** `Branded<number>` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home.
**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.
**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.
## Consequences
@@ -38,4 +44,6 @@ 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.
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.
@@ -18,6 +18,8 @@ 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()` 就是全部操作集合。
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 其余测试只断言错误码,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
@@ -30,6 +32,10 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。
**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded<number>`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。
**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。
**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。
## Consequences
@@ -38,4 +44,6 @@ 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`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有其不变量所校验的持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
+11 -8
View File
@@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-llm' {
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import { Duration } from '@deepseek-ai/dsh-util-time'
import type {} from '@deepseek-ai/dsh-session-projection'
import {
deriveBrowserTimeZoneContext,
@@ -66,15 +67,17 @@ export const Config: z<Config> = z.object({
refreshIntervalMs: z.number(),
})
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
/**
* Format a non-negative elapsed millisecond count as compact whole-second units.
*
* A backward wall-clock reading is a property of the clock rather than of the
* span, so it clamps to zero here; the shared span type then supplies the whole
* parts, and this package keeps the wording.
*/
function formatDuration(elapsedMs: number): string {
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
const days = Math.floor(seconds / 86_400)
seconds %= 86_400
const hours = Math.floor(seconds / 3600)
seconds %= 3600
const minutes = Math.floor(seconds / 60)
seconds %= 60
const { days, hours, minutes, seconds } = Duration
.fromMilliseconds(Math.max(0, elapsedMs))
.decompose()
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
+3 -3
View File
@@ -9,14 +9,14 @@
en: 7283f2afb343d150
zh: 32c56bd67c0314c6
/util-shared-utilities/summary:
en: 2424d44f0d79be74
en: f21c74532a9c929e
zh: 5bc05166e8d83c1e
/util-shared-utilities/table-of-contents:
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/util-shared-utilities/packages:
en: 662b2baeac105692
zh: f076c6d39c2985b1
en: 44eb08c6c7a5bbfb
zh: 9857e5374e5b14de
/util-shared-utilities/related-documentation:
en: 432ae81110914571
zh: c5bf4efc007311f3
+2 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone and instant vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics.
The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone, instant, and span vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics.
## Table of Contents
@@ -41,7 +41,7 @@ Each package provides one primitive; open a package page for how to use it.
| [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers |
| [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language that the Code preview, diff review, and the read card render |
| [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata |
| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones |
| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones, and holds a millisecond span |
| [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification |
-----
+1 -1
View File
@@ -41,7 +41,7 @@ kind: "package-group"
| [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 |
| [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片所渲染的语法高亮语言 |
| [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 |
| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区 |
| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区,并持有毫秒精度的时间段 |
| [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 |
-----
+12 -12
View File
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/time/README.md
/:
en: 78b8a5dd36b637f5
zh: 451e95d5bc7d5a66
en: 6daf8db648646a31
zh: 156a7d47413a6a55
/dsh-util-time:
en: bc299a9b8fe7717f
zh: 896661ee0c4edf25
/dsh-util-time/summary:
en: 6d4d39093c2a2b5a
zh: 1dc4af01352581f2
en: cdc849b4f44094b1
zh: b03edaa6a020d39d
/dsh-util-time/table-of-contents:
en: d152484eb41ac6b4
zh: f14f34fce35a8d90
@@ -18,14 +18,14 @@
en: bbff250eba0f1b89
zh: 0a618f05cc88ee2b
/dsh-util-time/use-this-package/when-to-use-it:
en: f97e654746aa3712
zh: 52ba385e9f3b96a9
en: 7015b3ed33de4c53
zh: d4fc9aee6f6910ca
/dsh-util-time/use-this-package/entry-point:
en: b5b79b2f1617b788
zh: 72606179d6a7c33c
en: 306f0f5d65af8223
zh: a79b60c39be361c8
/dsh-util-time/understand-the-implementation:
en: c814c83b60f43e0c
zh: 89021dc77356392b
en: f42cb0fc332d7740
zh: f980bcae2f72fdd8
/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: 8b29ec4add8f93e6
zh: ee9bfd6224a4204c
en: b4108682b9b18245
zh: 7dfb014c2dc1775f
/dsh-util-time/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
+12 -6
View File
@@ -1,5 +1,5 @@
---
description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing for maintainers accepting a caller-reported zone or a wall-clock selector at a boundary."
description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing, plus a millisecond span type, for maintainers accepting a caller-reported zone, a wall-clock selector, or an elapsed time at a boundary."
kind: "package-library"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
Shared time vocabulary for the boundaries that accept a caller's zone or a wall-clock selector. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling, so an alias never reaches a durable record. A wall-clock consumer instead parses strict RFC 3339 instants, local calendar fields, and zone names through discriminated results, then resolves a local value with maintained `Temporal` DST and overlap behavior. The library owns no failure vocabulary or model-facing text: each caller maps a reason onto its own domain code.
Shared time vocabulary for the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time. `canonicalClientTimeZone` answers the platform-canonical `UTC` or IANA `Area/Location` spelling, so an alias never reaches a durable record. The strict parsers read RFC 3339 instants, local calendar fields, and zone names as discriminated results, and `resolveLocalInstant` applies maintained `Temporal` DST and overlap behavior. `Duration` holds a span in milliseconds and splits it into whole parts. The library owns no failure vocabulary and no model-facing text: each caller maps a reason onto its own domain code.
## Table of Contents
@@ -29,14 +29,14 @@ Shared time vocabulary for the boundaries that accept a caller's zone or a wall-
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state.
Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value.
Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. `dsh-time-context` reads its elapsed reading through `Duration` and keeps the wording of the line it injects. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value.
No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry.
### Entry point
```ts
import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined
const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason
@@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept
if (local.kind === 'accepted') {
resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap
}
Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 }
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.
`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.
-----
@@ -71,6 +74,7 @@ The strict parsers match a fixed regular expression before constructing any date
| `formatFourDigitYearInstant` | An integral epoch inside the four-digit-year range; answers `undefined` outside it or when non-integral. |
| `isFourDigitYearInstant` | The same range, as a predicate, so a caller that only needs the bound does not re-derive it. |
| `resolveLocalInstant` | Exact calendar fields of a real date, or an already-built `Temporal.PlainDateTime`, plus an already-canonical zone. |
| `Duration` | One finite millisecond count, built from milliseconds or seconds, and never a bare count on read. |
</details>
@@ -100,7 +104,9 @@ None of its own. The consumer that injects a zone- or schedule-derived line into
<a id="known-limitations-and-deferred-work"></a>
- **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, and canonical formatting only; a caller that walks candidate dates or intervals owns that search.
- **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.
- **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>
+12 -6
View File
@@ -1,5 +1,5 @@
---
description: "面向在协议边界接收调用方所报时区或墙钟选择器的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析。"
description: "面向在协议边界接收调用方所报时区、墙钟选择器或耗时的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析,以及毫秒精度的时间段类型。"
kind: "package-library"
---
@@ -9,7 +9,7 @@ kind: "package-library"
## 概述
共享时间词汇,供接收调用方时区或墙钟选择器的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答当前平台上的规范拼写,因此别名不会进入持久记录。需要墙钟语义的消费方则通过带判别值的结果解析严格的 RFC 3339 时刻、本地日历字段与时区名称,再借助受维护的 `Temporal` 解析本地值,获得 DST 与重叠时段的既定行为。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。
共享时间词汇,供接收调用方时区、墙钟选择器或耗时的边界使用。`canonicalClientTimeZone` 回答当前平台上的规范 `UTC` 或 IANA `Area/Location` 拼写,因此别名不会进入持久记录。严格解析器以带判别值的结果读取 RFC 3339 时刻、本地日历字段与时区名称,`resolveLocalInstant` 则套用受维护的 `Temporal` 的 DST 与重叠行为。`Duration` 以毫秒保存一段时间并拆成整数量。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。
## 目录
@@ -29,14 +29,14 @@ kind: "package-library"
它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。
在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。
在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。
不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。
### 入口
```ts
import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined
const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason
@@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept
if (local.kind === 'accepted') {
resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap
}
Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 }
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`。
不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限,加减与排序都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。
-----
@@ -71,6 +74,7 @@ if (local.kind === 'accepted') {
| `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 |
| `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 |
| `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 |
| `Duration` | 一个有限的毫秒计数,可由毫秒或秒构造,读取时从不交出裸计数。 |
</details>
@@ -100,7 +104,9 @@ if (local.kind === 'accepted') {
<a id="known-limitations-and-deferred-work"></a>
- **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。
- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析与规范格式化;遍历候选日期或间隔的搜索由调用方自己拥有。
- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。
- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。
- **`Duration` 不做隐式单位换算**——它没有 `valueOf` 也没有 `toString`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。
- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。
<a id="dev-note"></a>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-util-time",
"description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), and strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors",
"description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors, and a millisecond Duration span",
"version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
+184
View File
@@ -0,0 +1,184 @@
/**
* One span of time, held as a count of milliseconds.
*
* A class rather than a branded number: arithmetic on two branded numbers
* answers an unbranded `number`, so a brand cannot stop seconds and
* milliseconds from being added, while an instance can. There is no `valueOf`
* and no `toString`, so no reader ever receives a bare count whose unit it has
* to remember; a caller states the unit by reading `milliseconds` or by
* converting through one of the `to*` entries.
* @module @deepseek-ai/dsh-util-time
*/
const MS_PER_SECOND = 1_000
const MS_PER_MINUTE = 60 * MS_PER_SECOND
const MS_PER_HOUR = 60 * MS_PER_MINUTE
const MS_PER_DAY = 24 * MS_PER_HOUR
const SECONDS_PER_DAY = 86_400
const SECONDS_PER_HOUR = 3_600
const SECONDS_PER_MINUTE = 60
/** Whole parts of one non-negative span, in the units a reading is written in. */
export interface DurationParts {
/** Whole days. */
readonly days: number
/** Whole hours left after the days, from 0 through 23. */
readonly hours: number
/** Whole minutes left after the hours, from 0 through 59. */
readonly minutes: number
/** Whole seconds left after the minutes, from 0 through 59. */
readonly seconds: number
/** Fractional milliseconds left after the seconds, from 0 up to (but excluding) 1000. */
readonly milliseconds: number
}
/**
* 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.
*/
export class Duration {
/** The additive identity. */
static readonly zero: Duration = new Duration(0)
private readonly span: number
private constructor(milliseconds: number) {
this.span = milliseconds
Object.freeze(this)
}
/**
* Build one span from a millisecond count.
* @param milliseconds - Finite millisecond count; a fractional count is kept as given.
* @returns The span.
* @throws TypeError when the count is not a finite number.
*/
static fromMilliseconds(milliseconds: number): Duration {
if (!Number.isFinite(milliseconds)) {
throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`)
}
return new Duration(milliseconds)
}
/**
* Build one span from a second count, for a boundary whose unit is seconds.
* @param seconds - Finite second count; a fractional count is kept as given.
* @returns The span.
* @throws TypeError when the count is not finite, or its millisecond equivalent is not.
*/
static fromSeconds(seconds: number): Duration {
if (!Number.isFinite(seconds)) {
throw new TypeError(`duration seconds must be finite, got ${String(seconds)}`)
}
return Duration.fromMilliseconds(seconds * MS_PER_SECOND)
}
/** The span as a millisecond count; the unit is part of the name. */
get milliseconds(): number {
return this.span
}
/**
* Add one span to this span.
* @param other - Span to add; both operands already share the millisecond unit.
* @returns The sum.
*/
plus(other: Duration): Duration {
return new Duration(this.span + other.span)
}
/**
* Subtract one span from this span.
* @param other - Span to subtract.
* @returns The difference, which is negative when `other` is longer.
*/
minus(other: Duration): Duration {
return new Duration(this.span - other.span)
}
/**
* Order two spans.
* @param other - Span to compare with.
* @returns `-1` when this span is shorter, `0` when equal, `1` when longer.
*/
compare(other: Duration): -1 | 0 | 1 {
if (this.span < other.span) return -1
return this.span > other.span ? 1 : 0
}
/**
* Whether the span is a negative interval, which every sign-sensitive reader
* must decide how to handle.
* @returns Whether the millisecond count is below zero.
*/
isNegative(): boolean {
return this.span < 0
}
/**
* The span in seconds.
* @returns The span as a fractional second count, lossless in both directions.
*/
toSeconds(): number {
return this.span / MS_PER_SECOND
}
/**
* The span in minutes.
* @returns The span as a fractional minute count, lossless in both directions.
*/
toMinutes(): number {
return this.span / MS_PER_MINUTE
}
/**
* The span in hours.
* @returns The span as a fractional hour count, lossless in both directions.
*/
toHours(): number {
return this.span / MS_PER_HOUR
}
/**
* The span in days.
*
* 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.
*/
toDays(): number {
return this.span / MS_PER_DAY
}
/**
* 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.
* @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.
*/
decompose(): DurationParts {
if (this.span < 0) {
throw new RangeError('a negative duration has no whole-part decomposition')
}
let seconds = Math.floor(this.span / MS_PER_SECOND)
const days = Math.floor(seconds / SECONDS_PER_DAY)
seconds %= SECONDS_PER_DAY
const hours = Math.floor(seconds / SECONDS_PER_HOUR)
seconds %= SECONDS_PER_HOUR
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
seconds %= SECONDS_PER_MINUTE
return Object.freeze({
days,
hours,
minutes,
seconds,
milliseconds: this.span - Math.floor(this.span / MS_PER_SECOND) * MS_PER_SECOND,
})
}
}
+4 -2
View File
@@ -1,6 +1,6 @@
/**
* Time vocabulary shared by the boundaries that accept a caller's zone or a
* wall-clock selector.
* wall-clock selector, plus one span type for elapsed and interval arithmetic.
*
* Validation, canonicalization, and strict parsing only: this library formats
* no model-facing text and owns no failure vocabulary. A refusal that carries a
@@ -8,12 +8,14 @@
* own domain code and diagnostic. `canonicalClientTimeZone` is the wire-boundary
* form and answers a name or `undefined`; `formatFourDigitYearInstant` and
* `resolveLocalInstant` take already-validated values and answer a string or
* epoch milliseconds directly.
* epoch milliseconds directly; `Duration` holds a span in milliseconds with no
* implicit numeric conversion.
* @module @deepseek-ai/dsh-util-time
*/
import { canonicalizeTimeZone } from './instant.ts'
export * from './duration.ts'
export * from './instant.ts'
/**
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { Duration } from '@deepseek-ai/dsh-util-time'
describe('Duration construction', () => {
it('holds the millisecond count it was built from, fraction included', () => {
expect(Duration.fromMilliseconds(1_500).milliseconds).toBe(1_500)
expect(Duration.fromMilliseconds(0.5).milliseconds).toBe(0.5)
expect(Duration.zero.milliseconds).toBe(0)
})
it('converts a second count at the boundary that carries seconds', () => {
expect(Duration.fromSeconds(300).milliseconds).toBe(300_000)
expect(Duration.fromSeconds(0.25).milliseconds).toBe(250)
expect(Duration.fromSeconds(-60).milliseconds).toBe(-60_000)
})
it('refuses a count that is not a finite number', () => {
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
expect(() => Duration.fromMilliseconds(value)).toThrow(TypeError)
expect(() => Duration.fromSeconds(value)).toThrow(TypeError)
}
})
it('refuses a finite second count whose millisecond equivalent leaves the finite range', () => {
expect(() => Duration.fromSeconds(1e306)).toThrow(TypeError)
})
it('is frozen, and carries no implicit numeric 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)
// Without a `valueOf` or `toString` override, a coercion never answers the count.
expect(Number(span)).toBe(Number.NaN)
expect(String(span)).toBe('[object Object]')
})
})
describe('Duration arithmetic', () => {
it('adds and subtracts in the one shared unit', () => {
expect(Duration.fromSeconds(2).plus(Duration.fromMilliseconds(500)).milliseconds).toBe(2_500)
expect(Duration.fromSeconds(2).minus(Duration.fromMilliseconds(500)).milliseconds).toBe(1_500)
expect(Duration.fromMilliseconds(1).minus(Duration.fromSeconds(1)).isNegative()).toBe(true)
expect(Duration.zero.plus(Duration.zero).milliseconds).toBe(0)
})
it('orders spans without surrendering the unit', () => {
const minute = Duration.fromSeconds(60)
const hour = Duration.fromSeconds(3_600)
expect(minute.compare(hour)).toBe(-1)
expect(hour.compare(minute)).toBe(1)
expect(minute.compare(Duration.fromMilliseconds(60_000))).toBe(0)
expect(minute.isNegative()).toBe(false)
expect(Duration.fromMilliseconds(-1).isNegative()).toBe(true)
})
})
describe('Duration conversion', () => {
it('converts to larger units fractionally, so nothing is lost either way', () => {
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)
})
it('covers a day as exactly twenty-four hours', () => {
expect(Duration.fromMilliseconds(86_400_000).toDays()).toBe(1)
expect(Duration.fromMilliseconds(-86_400_000).toDays()).toBe(-1)
})
})
describe('Duration decompose', () => {
it('splits a span into floored whole parts and the fractional remainder', () => {
expect(Duration.fromMilliseconds(0).decompose())
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
expect(Duration.fromMilliseconds(999).decompose())
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 999 })
expect(Duration.fromMilliseconds(1_000).decompose())
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 0 })
expect(Duration.fromMilliseconds(90_061_500).decompose())
.toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500 })
expect(Duration.fromMilliseconds(86_400_000).decompose())
.toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
expect(Duration.fromMilliseconds(90_061_500.75).decompose())
.toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500.75 })
})
it('answers frozen parts whose sum never exceeds the span', () => {
const parts = Duration.fromMilliseconds(3_661_999).decompose()
expect(Object.isFrozen(parts)).toBe(true)
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('refuses a negative span instead of inventing a sign policy', () => {
expect(() => Duration.fromMilliseconds(-1).decompose()).toThrow(RangeError)
})
})