feat(environment): preserve view-specific written clock spellings

Share clock rendering between analysis and local serving. Preserve exact sub-millisecond facts, bound learned format state per field, and charge retained output to the projection budget.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
Edgar Arakelyan authored and GitHub committed 2026-10-23 00:12:26 +08:00
1 parent 71dc6fe5bd
commit 957bcfa149
16 files changed
+619 -44

No files matched your search

+5
View File
@@ -429,6 +429,11 @@ Each read tool answers according to its view in the state:
| `document` | When the arguments name the document's checked line exactly, the state's value for it; a tool error when the state holds none. |
| `recorded` | The recording, as replay answers it. |
An item or list view that always spells a clock field otherwise than the
entity's stored value names that spelling in `clock_formats`, and a value it
never recorded, such as a created entity's clock, is rendered in it: a list that
writes `+00:00` where item reads write `Z` lists a new entity the same way.
A list or document call that names no checked line, including a line recorded
after a write or with other query values, answers from the recording. Every
answer has the replay shape plus `state`: `{"source": "state", "view": …}`
@@ -47,6 +47,15 @@ later increments.
write is volatile and keeps the latest; a change across a write under another
root, including between two tools of the type, leaves every tool of the type
recorded. Compare list membership, including empty lists, and field presence before selecting each request line's latest recording; a change across a write also leaves the type recorded. Different query strings remain distinct; omitted fields do not erase their earlier observations on other request lines.
- An item or list view whose recordings always spell a clock field's instant
otherwise than the entity's stored value (`Z` against `+00:00`, another
separator or fraction width) names that spelling in `clock_formats`; a field
it also shows as stored, or spells two ways (counting values at another
instant), gets none. A stored value the view never recorded, such as one a
write made, is rendered in that spelling: its wall clock in the view's zone,
under the view's separator, with its own fraction digits padded with zeros to
the view's width and none dropped; an epoch spelling is computed from the
instant.
- A list has `all` membership only when no item-total envelope member (`total`,
`count` and their compounds, not page counts) differs from the number of
entities it lists and no `has_more`, `next` or next-page token is set.
@@ -0,0 +1,8 @@
# Implementation plan
1. **Re-spelling.** A values helper spells a clock in another format: its wall clock in the format's zone, with its own
fraction digits padded to the format's width.
2. **Learning.** The gate notes, per view, the spelling of each clock field that differs from the stored value, while it
checks the view's recordings; the view gains `clock_formats` when any field always differs.
3. **Rendering.** Entity rendering spells a stored value in the view's spelling when the view has no value of its own.
4. **Schema, tests and docs.** The view schema, gate, serving and unit tests, and the state-view docs.
@@ -0,0 +1,37 @@
# Spell a written clock as each view spells it
Issue: https://github.com/morluto/rea/issues/4933
## Outcome
Some applications spell one instant differently by view. Mealie's recipe reads end timestamps in `Z`
(`2026-10-21T17:06:13.710410Z`) and its recipe list in `+00:00`. The state keeps each view's own values for the entities
it recorded, so those render as recorded, but a created or updated entity has only the values the write showed. A list
read after a create therefore rendered the new recipe's clocks in the item spelling. The write gate saw a contradiction
and left the create unserved, and serving would have listed the wrong spelling.
## Behaviour
- **Learning.** While checking that an item or list view reproduces its recordings, the gate compares each recorded
object's clock fields with the entity's stored values. A field whose recorded value always names the same instant in
one other spelling gets that spelling in the view's optional `clock_formats`. A field the view also shows as stored, or
spells two ways, counting values that name another instant, gets none. A spelling has at most nine fraction digits.
Instant equality includes all recorded fraction digits, not just milliseconds; offset changes and trailing zeros
alone do not change the instant. The clock parser owns this precision and rendering reuses its parsed facts.
- **Bounds.** Each field retains at most one matching and one other-instant spelling, or a permanent mixed marker
for either category. Rejected formats do not accumulate. The existing projection budget covers the learner's maps,
keys, formats and returned view members before retention; exhaustion keeps the existing typed resource refusal.
Preliminary list-query probes do not learn or retain clock state; only the final admitted view does.
- **Rendering.** A stored value the view never recorded, such as one a write made, is rendered in the view's spelling:
its wall clock in the view's zone, under the view's separator, with its own fraction digits padded with zeros to the
view's width. No digit is dropped, so a width learned from whole seconds does not cut a later value's fraction. An
epoch spelling is computed from the instant.
The gate and serving render through the same function, so a served create lists with the list's spelling and reads back
with the item's. `data/get` returns the stored values.
## Acceptance
- The pilot recording's Mealie create is served on the list-query stack.
- Over the 50 published captures, one view learns a spelling (Mealie's recipe list), and every write verdict is unchanged.
- Every served write tool of the 50 captures still applies.
@@ -0,0 +1,9 @@
# Tasks
- [x] Re-spell a clock in another format, keeping digits past milliseconds.
- [x] Learn each view's clock spellings while checking its recordings.
- [x] Render stored values a view never recorded in its spelling.
- [x] Add gate, serving and unit tests, failing on the previous head.
- [x] Document the view member.
- [x] Measure the pilot recordings, the 50 published captures and the served smoke.
- [x] Run static, formatting, duplication, documentation and test gates.
@@ -0,0 +1,44 @@
# Verification
Executed on macOS with Node v22.23.1 on this branch with the write-plan branch's head `45a5c8b9` merged in. The pilot recordings
were made against live applications on six disposable Linux VMs, each restored from a snapshot first.
## Gates
- `npm run build:cached`, `npm run check:fast` (typecheck and lint, no warnings in the changed files),
`npm run format:check`, `npx knip`, `npm run scan:todos`, `npm run docs:check`, `npm run verify:test-discovery` and
`npm run mcp-catalog:check`: passed.
- `npm run jscpd`: 3.43% duplicated lines over 3753 files.
- `npx vitest run --project domain --project services`: 7755 passed, 5 pre-existing skips.
- `npm run test:prepare`, then `npm run test:focused --` the environment serve and export CLI boundaries, the serve
and served-writes MCP boundaries, the analysis-view CLI and MCP boundaries, the compact input schema boundary and
tool schema validity: 8 files, 79 passed.
## Tests
- Gate: a list that spells clocks `+00:00` where item reads spell `Z` learns both spellings, the item view learns none,
and a create read back by both is served; a create whose clock has a fraction is served where the list's spelling was
learned from whole seconds; a list that spells a field both ways, at the stored instant or another, learns nothing.
- Serving: a served create lists with the list's spelling and reads back with the item's.
- Unit: re-spelling keeps a value's fraction digits at any offset and across a date change, pads them to the format's
width without dropping any, and formats an epoch spelling from the instant; an epoch value's milliseconds become
fraction digits.
- Schema: a view clock spelling wider than nine fraction digits is refused.
- The gate and serving tests fail on `264ce410`, the write-plan head they were written on.
## Pilot recordings
Mealie's recipe reads spell clocks `Z` and its recipe list `+00:00`. On this branch alone the list line the create is
read back on keeps its recorded members, so the create stays unserved; per-line list membership and list queries
(#5787, #5800) answer that line from the state. Built on the list-query stack (#5800 with #5699 merged):
- Mealie's `post_recipes` goes from `contradicted` to served, and Mealie's held-out sessions agree on 298 of 310 steps,
up from 294.
- With the assigned-values branch (#6136) too, all three Mealie write tools are served, at 302 of 310.
- The other five applications are unchanged.
## Published captures
- Over the 50 published captures, one view learns a spelling: Mealie's recipe list (`dateUpdated`, `createdAt`,
`updatedAt`). Every write verdict is unchanged.
- The served smoke applies all 31 served write tools.
@@ -20,6 +20,7 @@ import {
HANDLE_ROWS,
LIST_LINE_ROWS,
QUERY_ROWS,
OFFSET_LIST_ROWS,
SECOND_BOOK,
THIRD_BOOK,
assetBook,
@@ -30,6 +31,7 @@ import {
VERSIONED_ROWS,
numberedUuid,
bookPage,
offsetListed,
renamedBook,
type Book,
} from "../../../tests/fixtures/environmentStateWrites.js";
@@ -451,6 +453,19 @@ describe("values the application assigns", () => {
});
});
describe("clock spellings per view", () => {
it("lists a created book with the list's clock spelling and reads it back with the item's", () => {
const books = servedFrom(OFFSET_LIST_ROWS);
// The served clock starts after the sixth recorded exchange.
const created = book(3, "New Book", bookClock(7));
books.answer("post_books", CREATE_NEW_BOOK);
expect(books.answer("get_books").body).toEqual(
[FIRST_BOOK, SECOND_BOOK, created].map(offsetListed),
);
expect(books.answer("get_books_by_id", { id: "3" }).body).toEqual(created);
});
});
describe("refusals", () => {
it("refuses a body the capture does not support and leaves the state unchanged", () => {
const books = servedFrom();
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
checkItemPlan,
type StateGateContext,
} from "./environmentStateGate.js";
import type {
JsonObject,
StateEntity,
StateRecordFacts,
} from "./environmentStateRules.js";
/** Exercise the production gate's retention contract independently of capture/body allocation. */
const check = (
clocks: readonly (readonly [string, string])[],
limit = Infinity,
) => {
let bytes = 0;
const records: StateRecordFacts[] = clocks.map((_, ordinal) => ({
ordinal,
method: "GET",
path: `/api/books/${ordinal + 1}`,
query: undefined,
status: 200,
start: ordinal,
end: ordinal,
}));
const bodies: JsonObject[] = clocks.map(([, stamp], index) => ({
id: index + 1,
stamp,
}));
const entities: StateEntity[] = clocks.map(([stamp], index) => ({
type: "/api/books",
collection: "/api/books",
id: String(index + 1),
fields: { id: index + 1, stamp },
view_fields: [
{ tool: "get_books_by_id", fields: { stamp: clocks[index]![1] } },
],
record_ordinals: [index],
}));
const context: StateGateContext = {
withBody: (ordinal, use) => use(bodies[ordinal]),
retain: (amount) => {
bytes += amount;
if (bytes > limit) throw new Error("gate budget exhausted");
},
entity: (_type, _collection, id) => entities[Number(id) - 1],
inCollection: () => entities,
volatileOf: () => new Set(),
idField: () => "id",
};
const view = checkItemPlan(context, {
tool: {
name: "get_books_by_id",
method: "GET",
path_template: "/api/books/{id}",
record_ordinals: records.map((r) => r.ordinal),
},
item: true,
type: "/api/books",
pointer: "",
recordings: records,
latest: records,
});
return { view, bytes };
};
describe("clock spelling facts at the read gate", () => {
it.each([
["2026-10-21T17:06:13.123456Z", "2026-10-21T17:06:13.123789+00:00"],
["2026-10-21T17:06:13.123456789Z", "2026-10-21T17:06:13.123456788+00:00"],
])(
"does not learn a spelling from distinct sub-millisecond instants (%s)",
(stored, shown) => {
expect(check([[stored, shown]]).view).not.toHaveProperty("clock_formats");
},
);
it("learns matching nanosecond instants across offsets and trailing zeros", () => {
expect(
check([
["2026-10-21T17:06:13.123456Z", "2026-10-21T19:06:13.123456000+02:00"],
]).view,
).toMatchObject({
clock_formats: { stamp: { kind: "iso", fraction: 9, zone: "+02:00" } },
});
});
it("charges clock facts and their output before they escape a tight gate budget", () => {
const baseline = check([["not a clock", "not a clock"]]).bytes;
const clocks = [
["2026-10-21T17:06:13Z", "2026-10-21T17:06:13+00:00"],
] as const;
expect(() => check(clocks, baseline)).toThrow("gate budget exhausted");
expect(check(clocks).bytes).toBeGreaterThan(baseline);
const unmatched = check([[clocks[0][0], "2026-10-20T17:06:13+00:00"]]);
expect(check(clocks).bytes).toBeGreaterThan(unmatched.bytes);
expect(() => check(clocks, unmatched.bytes)).toThrow(
"gate budget exhausted",
);
});
it("keeps only a mixed marker after incompatible other-instant formats", () => {
const clocks = (count: number) =>
Array.from(
{ length: count },
(_, index) =>
[
"2026-10-21T17:06:13Z",
`2026-10-20T17:06:13+${String(Math.floor(index / 60) % 24).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}`,
] as const,
);
const small = check(clocks(4));
const large = check(clocks(1024));
expect(large.view).not.toHaveProperty("clock_formats");
expect(large.bytes).toBe(small.bytes);
});
});
+114 -15
View File
@@ -30,6 +30,7 @@ import {
} from "./environmentStateListQuery.js";
import { singleQueryValues } from "../recordedTransportUrl.js";
import { startsAtBeginning } from "./environmentStatePaging.js";
import { readClock, type ClockFormat } from "./environmentStateWriteValues.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
/** A read tool whose recordings locate entities of one type at one pointer. */
@@ -96,6 +97,85 @@ class FieldUnion {
}
}
/** The spellings a view always gives clock fields where its recordings spell the stored instant otherwise. */
class ClockSpellings {
/** Each category retains one spelling or a permanent mixed marker, never an expanding format history. */
readonly #fields = new Map<
string,
{
matched: ClockFormat | null | undefined;
other: ClockFormat | null | undefined;
}
>();
constructor(private readonly retain: StateGateContext["retain"]) {
retain(ENTRY_BYTES);
}
#note(
field: string,
category: "matched" | "other",
format: ClockFormat | null,
): void {
let facts = this.#fields.get(field);
if (facts === undefined) {
this.retain(ENTRY_BYTES + textBytes(field.length));
facts = { matched: undefined, other: undefined };
this.#fields.set(field, facts);
}
const held = facts[category];
if (held === undefined) {
if (format !== null)
this.retain(
ENTRY_BYTES +
(format.kind === "iso" ? textBytes(format.zone.length) : 0),
);
facts[category] = format;
} else if (held !== null && !same(held, format)) facts[category] = null;
}
note(stored: JsonObject, shown: JsonObject): void {
for (const [field, value] of Object.entries(shown)) {
const now = readClock(value);
if (now === undefined) continue;
const was = Object.hasOwn(stored, field)
? readClock(stored[field])
: undefined;
if (
was === undefined ||
was.ms !== now.ms ||
was.subMillisecond !== now.subMillisecond
) {
this.#note(field, "other", now.format);
continue;
}
const spelled = same(stored[field], value) ? null : now.format;
this.#note(field, "matched", spelled);
}
}
/** The view member, when the view spells any field otherwise, and in that spelling only. */
get view(): { readonly clock_formats?: Record<string, ClockFormat> } {
const formats: [string, ClockFormat][] = [];
for (const [field, { matched, other }] of this.#fields) {
if (
matched === undefined ||
matched === null ||
other === null ||
(other !== undefined && !same(other, matched))
)
continue;
if (formats.length === 0) this.retain(ENTRY_BYTES * 2);
// Both the temporary entry and its returned object member remain covered.
this.retain(ENTRY_BYTES * 2 + textBytes(field.length));
formats.push([field, matched]);
}
return formats.length === 0
? {}
: { clock_formats: Object.fromEntries(formats) };
}
}
const recorded = (plan: StatePlan, reason: RecordedViewReason): StateView => ({
tool: plan.tool.name,
shape: "recorded",
@@ -109,6 +189,7 @@ const itemMismatch = (
check: {
readonly view: { readonly tool: string; readonly fields: string[] };
readonly envelope: { skeleton?: string };
readonly spellings: ClockSpellings;
},
record: StateRecordFacts,
): RecordedViewReason | undefined => {
@@ -131,6 +212,7 @@ const itemMismatch = (
envelope.skeleton = skeleton;
}
if (skeleton !== envelope.skeleton) return "envelope-varies";
check.spellings.note(entity.fields, object);
const shown = renderStateEntity(
entity,
view,
@@ -155,8 +237,14 @@ export const checkItemPlan = (
context.retain(union.bytes);
const view = { tool: plan.tool.name, fields: union.fields };
const envelope: { skeleton?: string } = {};
const spellings = new ClockSpellings(context.retain);
for (const record of plan.latest) {
const reason = itemMismatch(context, plan, { view, envelope }, record);
const reason = itemMismatch(
context,
plan,
{ view, envelope, spellings },
record,
);
if (reason !== undefined) return recorded(plan, reason);
}
return {
@@ -171,6 +259,7 @@ export const checkItemPlan = (
...(plan.projected === undefined || plan.projected.length === 0
? {}
: { projected_record_ordinals: [...plan.projected] }),
...spellings.view,
};
};
@@ -341,16 +430,20 @@ const listQuery = (
const listReproduces = (
context: StateGateContext,
plan: StatePlan,
view: {
readonly tool: string;
readonly fields: string[];
readonly pointer: string;
readonly counters: string[];
readonly whole: ReadonlySet<number>;
readonly query: ListQuery | undefined;
check: {
readonly view: {
readonly tool: string;
readonly fields: string[];
readonly pointer: string;
readonly counters: string[];
readonly whole: ReadonlySet<number>;
readonly query: ListQuery | undefined;
};
readonly spellings?: ClockSpellings;
},
record: StateRecordFacts,
): boolean => {
const { view, spellings } = check;
const field = context.idField(plan.type);
const collection = collectionPathOf(record, false);
return context.withBody(record.ordinal, (body) => {
@@ -378,14 +471,16 @@ const listReproduces = (
);
return entity === undefined ? [] : [entity];
}));
const rendered = members.map((entity) =>
renderStateEntity(
const rendered = members.map((entity) => {
const item = observed.get(entity.id);
if (item !== undefined) spellings?.note(entity.fields, item);
return renderStateEntity(
entity,
view,
context.volatileOf(plan.type),
observed.get(entity.id),
),
);
item,
);
});
const answer =
view.query === undefined ||
(computed === undefined && !view.whole.has(record.ordinal))
@@ -421,7 +516,7 @@ export const checkListPlan = (
const failing = plan.latest.filter(
(record) =>
query!.record_ordinals.includes(record.ordinal) &&
!listReproduces(context, plan, { ...base, query }, record),
!listReproduces(context, plan, { view: { ...base, query } }, record),
);
const kept = query.record_ordinals.filter(
(ordinal) => !failing.some((record) => record.ordinal === ordinal),
@@ -430,8 +525,11 @@ export const checkListPlan = (
}
const view = { ...base, query };
const all = plan.latest.every((record) => whole.has(record.ordinal));
const spellings = new ClockSpellings(context.retain);
if (
!plan.latest.every((record) => listReproduces(context, plan, view, record))
!plan.latest.every((record) =>
listReproduces(context, plan, { view, spellings }, record),
)
)
return recorded(plan, "list-differs");
return {
@@ -452,5 +550,6 @@ export const checkListPlan = (
counters,
...(query === undefined ? {} : { query }),
checked_record_ordinals: plan.latest.map((record) => record.ordinal),
...spellings.view,
};
};
@@ -2,6 +2,10 @@ import type { ListQuery } from "./environmentStateListQuery.js";
import { canonicalJson } from "../comparisonSemantics.js";
import type { JsonValue } from "../jsonValue.js";
import type { StateWritePlan } from "./environmentStateWriteRules.js";
import {
respellClock,
type ClockFormat,
} from "./environmentStateWriteValues.js";
import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js";
/** How records are ordered: by recorded start time, or by record order when any start time is missing. */
@@ -99,6 +103,8 @@ interface EntityViewPlan {
readonly fields: string[];
readonly required_fields: string[];
readonly checked_record_ordinals: number[];
/** How the view spells the clock fields it always spells otherwise than the entities' stored values. */
readonly clock_formats?: Readonly<Record<string, ClockFormat>> | undefined;
}
export type StateItemView = EntityViewPlan & {
@@ -588,13 +594,14 @@ export const listItems = (
/** One entity as a view shows it: the view's fields it has, view-specific values first; volatile fields take `observed` values when given. */
export const renderStateEntity = (
entity: StateEntity,
view: Pick<EntityViewPlan, "tool" | "fields">,
view: Pick<EntityViewPlan, "tool" | "fields" | "clock_formats">,
volatileFields: ReadonlySet<string>,
observed?: JsonObject,
): JsonObject => {
const own =
entity.view_fields.find((candidate) => candidate.tool === view.tool)
?.fields ?? {};
const formats = view.clock_formats ?? {};
return Object.fromEntries(
view.fields.flatMap((name): [string, JsonValue][] => {
const source =
@@ -603,7 +610,16 @@ export const renderStateEntity = (
: Object.hasOwn(own, name)
? own
: entity.fields;
return Object.hasOwn(source, name) ? [[name, source[name]!]] : [];
if (!Object.hasOwn(source, name)) return [];
// A stored value the view never recorded, such as a written one, takes the view's spelling.
return [
[
name,
source === entity.fields && Object.hasOwn(formats, name)
? respellClock(source[name]!, formats[name]!)
: source[name]!,
],
];
}),
);
};
@@ -25,6 +25,7 @@ import {
replaceAt,
type JsonObject,
} from "./environmentStateRules.js";
import { environmentStateFields } from "./environmentStateView.js";
type State = Extract<AnalysisViewResult, { kind: "environment-state" }>;
const ALL_RULES = [
@@ -1207,3 +1208,19 @@ it("keeps a document out of the state when its value changed across a write unde
{ tool: "get_me", value: { credits: 10, plan: "free" }, record_ordinal: 2 },
]);
});
it("refuses a view clock spelling wider than nine fraction digits", () => {
const item = stateOf().views.find((view) => view.shape === "item")!;
const spelled = (fraction: number) => [
{
...item,
clock_formats: {
created: { kind: "iso", separator: "T", fraction, zone: "Z" },
},
},
];
expect(environmentStateFields.views.safeParse(spelled(9)).success).toBe(true);
expect(environmentStateFields.views.safeParse(spelled(10)).success).toBe(
false,
);
});
@@ -90,6 +90,15 @@ const checkedSchema = z
.describe(
"Latest init record per request line that the view reproduced from the state.",
);
const clockFormatSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("iso"),
separator: z.enum(["T", " "]),
fraction: z.number().int().nonnegative().max(9),
zone: z.string(),
}),
z.strictObject({ kind: z.literal("epoch"), unit: z.enum(["s", "ms"]) }),
]);
const entityViewFields = {
tool: z.string(),
type: z.string(),
@@ -103,6 +112,12 @@ const entityViewFields = {
.describe("Ordered fields the view shows of each entity."),
required_fields: z.array(z.string()),
checked_record_ordinals: checkedSchema,
clock_formats: z
.record(z.string(), clockFormatSchema)
.optional()
.describe(
"How the view spells clock fields it always spells otherwise than the entities' stored values; a stored value the view never recorded is spelled this way.",
),
};
const viewSchema = z.discriminatedUnion("shape", [
z.strictObject({
@@ -169,15 +184,6 @@ const viewSchema = z.discriminatedUnion("shape", [
]);
const ordinalSchema = z.number().int().nonnegative();
const clockFormatSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("iso"),
separator: z.enum(["T", " "]),
fraction: z.number().int().nonnegative(),
zone: z.string(),
}),
z.strictObject({ kind: z.literal("epoch"), unit: z.enum(["s", "ms"]) }),
]);
const ruled = { field: z.string() };
const fieldRuleSchema = z.discriminatedUnion("rule", [
z.strictObject({
@@ -8,6 +8,7 @@ import {
isUuid,
readClock,
replaceDelimited,
respellClock,
seededToken,
seededUuid,
sequenceNumber,
@@ -31,17 +32,20 @@ const iso = (
const seeds = (count: number): string[] =>
Array.from({ length: count }, (_, index) => `seed-${String(index)}`);
it.each<[string, number, ClockFormat]>([
it.each<[string, number, ClockFormat, number?]>([
["2026-03-04T05:06:07Z", WHOLE_SECOND, iso("T", 0, "Z")],
["2026-03-04 05:06:07.123Z", WITH_MS, iso(" ", 3, "Z")],
["2026-03-04T05:06:07.123456Z", WITH_MS, iso("T", 6, "Z")],
["2026-03-04T05:06:07.123456Z", WITH_MS, iso("T", 6, "Z"), 456000],
["2026-03-04T07:06:07.123+02:00", WITH_MS, iso("T", 3, "+02:00")],
["2026-03-03T23:36:07-0530", WHOLE_SECOND, iso("T", 0, "-0530")],
["2026-03-04T05:06:07", WHOLE_SECOND, iso("T", 0, "")],
["2024-02-29 00:00:00Z", Date.UTC(2024, 1, 29), iso(" ", 0, "Z")],
])("reads the ISO clock %s with its spelling", (text, ms, format) => {
expect(readClock(text)).toEqual({ ms, format });
});
])(
"reads the ISO clock %s with its spelling",
(text, ms, format, subMillisecond = 0) => {
expect(readClock(text)).toEqual({ ms, subMillisecond, format });
},
);
it.each([
"2026-02-30T00:00:00Z",
@@ -63,9 +67,16 @@ it.each([
it.each<[JsonValue | undefined, ReturnType<typeof readClock>]>([
[
WHOLE_SECOND / 1000,
{ ms: WHOLE_SECOND, format: { kind: "epoch", unit: "s" } },
{
ms: WHOLE_SECOND,
subMillisecond: 0,
format: { kind: "epoch", unit: "s" },
},
],
[
WITH_MS,
{ ms: WITH_MS, subMillisecond: 0, format: { kind: "epoch", unit: "ms" } },
],
[WITH_MS, { ms: WITH_MS, format: { kind: "epoch", unit: "ms" } }],
[999_999_999, undefined],
[99_999_999_999, undefined],
[10_000_000_000_000, undefined],
@@ -282,3 +293,41 @@ it("spells a sequence value in the recorded value's digit groups, the overflow l
expect(spelledLike("12", 13)).toBe("13");
expect(spelledLike(12, 13)).toBe(13);
});
it("respells a clock in another format, padding its fraction digits to the format's width but dropping none", () => {
const utc = iso("T", 6, "+00:00");
expect(respellClock("2026-10-21T17:06:13.710410Z", utc)).toBe(
"2026-10-21T17:06:13.710410+00:00",
);
expect(respellClock("2026-10-21 17:06:13Z", utc)).toBe(
"2026-10-21T17:06:13.000000+00:00",
);
expect(respellClock("2026-10-21T17:06:13.710410Z", iso("T", 0, "Z"))).toBe(
"2026-10-21T17:06:13.710410Z",
);
// An epoch spelling is formatted from the instant.
expect(
respellClock("2026-10-21T17:06:13Z", { kind: "epoch", unit: "s" }),
).toBe(Date.UTC(2026, 9, 21, 17, 6, 13) / 1000);
expect(respellClock("not a clock", utc)).toBe("not a clock");
});
it("respells a clock at another offset with its own fraction digits, across a date change", () => {
expect(
respellClock("2026-10-21T17:06:13.710410Z", iso("T", 6, "+02:00")),
).toBe("2026-10-21T19:06:13.710410+02:00");
expect(
respellClock("2026-10-21T23:30:00.000321Z", iso(" ", 6, "+05:30")),
).toBe("2026-10-22 05:00:00.000321+05:30");
expect(
respellClock("2026-10-21T01:00:00.5+01:00", iso("T", 3, "-03:00")),
).toBe("2026-10-20T21:00:00.500-03:00");
// An epoch value's milliseconds are its fraction digits.
const epochMs = Date.UTC(2026, 9, 21, 17, 6, 13, 250);
expect(respellClock(epochMs, iso("T", 0, "Z"))).toBe(
"2026-10-21T17:06:13.25Z",
);
expect(respellClock(epochMs, iso("T", 3, "+00:00"))).toBe(
"2026-10-21T17:06:13.250+00:00",
);
});
@@ -11,6 +11,13 @@ export type ClockFormat =
}
| { readonly kind: "epoch"; readonly unit: "s" | "ms" };
interface ClockReading {
readonly ms: number;
/** Nanoseconds after the floored millisecond, so distinct recorded instants stay distinct. */
readonly subMillisecond: number;
readonly format: ClockFormat;
}
const ISO_INSTANT =
/^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
const MINUTE_MS = 60_000;
@@ -25,9 +32,7 @@ const zoneMinutes = (zone: string): number => {
};
/** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */
const readIsoClock = (
value: string,
): { readonly ms: number; readonly format: ClockFormat } | undefined => {
const readIsoClock = (value: string): ClockReading | undefined => {
const match = ISO_INSTANT.exec(value);
if (match === null) return undefined;
const [year, month, day, separator, hour, minute, second] = match
@@ -56,6 +61,7 @@ const readIsoClock = (
return undefined;
return {
ms: local - zoneMinutes(zone) * MINUTE_MS,
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
format: {
kind: "iso",
separator: separator as "T" | " ",
@@ -68,30 +74,77 @@ const readIsoClock = (
/** The instant a clock value names and its format: an ISO-8601 date-time, or epoch seconds or milliseconds. */
export const readClock = (
value: JsonValue | undefined,
): { readonly ms: number; readonly format: ClockFormat } | undefined => {
): ClockReading | undefined => {
if (typeof value === "string") return readIsoClock(value);
if (typeof value !== "number" || !Number.isSafeInteger(value))
return undefined;
if (value >= 1e9 && value < 1e10)
return { ms: value * 1000, format: { kind: "epoch", unit: "s" } };
return {
ms: value * 1000,
subMillisecond: 0,
format: { kind: "epoch", unit: "s" },
};
if (value >= 1e12 && value < 1e13)
return { ms: value, format: { kind: "epoch", unit: "ms" } };
return {
ms: value,
subMillisecond: 0,
format: { kind: "epoch", unit: "ms" },
};
return undefined;
};
const pad = (value: number, width: number): string =>
String(value).padStart(width, "0");
/** An ISO-8601 date-time from the UTC fields of `at`, with fraction digits and a zone suffix. */
const isoText = (
at: Date,
separator: string,
digits: string,
zone: string,
): string =>
`${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
if (format.kind === "epoch")
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
const at = new Date(ms + zoneMinutes(format.zone) * MINUTE_MS);
const fraction =
format.fraction === 0
? ""
: `.${`${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(0, format.fraction)}`;
return `${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${format.separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${fraction}${format.zone}`;
const digits = `${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(
0,
format.fraction,
);
return isoText(at, format.separator, digits, format.zone);
};
/**
* A clock value spelled in another ISO format: its wall clock in the format's zone, under the format's separator, with
* the value's own fraction digits padded to the format's width, so no digit is lost; an epoch format is computed from
* the instant.
*/
export const respellClock = (
value: JsonValue,
format: ClockFormat,
): JsonValue => {
const clock = readClock(value);
if (clock === undefined) return value;
if (format.kind === "epoch") return formatClock(clock.ms, format);
const millis = ((clock.ms % 1000) + 1000) % 1000;
// An epoch value's fraction is its milliseconds, without trailing zeros.
const digits =
clock.format.kind === "epoch"
? pad(millis, 3).replace(/0+$/u, "")
: `${pad(millis, 3)}${pad(clock.subMillisecond, 6)}`.slice(
0,
clock.format.fraction,
);
const at = new Date(clock.ms - millis + zoneMinutes(format.zone) * MINUTE_MS);
return isoText(
at,
format.separator,
digits.padEnd(format.fraction, "0"),
format.zone,
);
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
@@ -19,6 +19,7 @@ import {
HANDLE_ROWS,
LIST_LINE_ROWS,
QUERY_ROWS,
OFFSET_LIST_ROWS,
SECOND_BOOK,
THIRD_BOOK,
assetBook,
@@ -26,6 +27,7 @@ import {
book,
numberedUuid,
versionedBook,
offsetListed,
bookClock,
detailedBook,
environmentStateViewOf,
@@ -461,6 +463,69 @@ describe("omissions shown by reads the gate ignores", () => {
});
});
describe("clock spellings per view", () => {
it("serves a create that a list spelling clocks otherwise than item reads confirms", () => {
const state = stateOf(OFFSET_LIST_ROWS);
const offset = { ...ISO_SECONDS, zone: "+00:00" };
expect(state.views.find((view) => view.tool === "get_books")).toMatchObject(
{
membership: "all",
clock_formats: { created_at: offset, updated_at: offset },
},
);
expect(
state.views.find((view) => view.tool === "get_books_by_id"),
).not.toHaveProperty("clock_formats");
expect(planOf(state, "post_books")).toMatchObject({
served: true,
checked_record_ordinals: [4, 5],
});
});
it("serves a create whose clock has a fraction where the list spelling was learned from whole seconds", () => {
const third = book(3, "Third Book", "2026-10-17T18:00:03.250Z");
const state = stateOf([
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK].map(offsetListed)],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", third, 201, { name: "Third Book" }],
["GET", "/api/books/3", third],
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK, third].map(offsetListed)],
]);
expect(planOf(state, "post_books")).toMatchObject({
served: true,
checked_record_ordinals: [4, 5],
});
});
it("learns no spelling for a field another recorded value spells as stored, at another instant", () => {
const moved = {
...SECOND_BOOK,
created_at: "2026-10-01T09:30:00Z",
updated_at: "2026-10-01T09:30:00Z",
};
const state = stateOf([
["GET", "/api/books", [offsetListed(FIRST_BOOK), moved]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
]);
const list = state.views.find((view) => view.tool === "get_books");
expect(list).toMatchObject({ shape: "list" });
expect(list).not.toHaveProperty("clock_formats");
});
it("learns no spelling for a field the view spells both ways", () => {
const state = stateOf([
["GET", "/api/books", [FIRST_BOOK, offsetListed(SECOND_BOOK)]],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
]);
expect(
state.views.find((view) => view.tool === "get_books"),
).not.toHaveProperty("clock_formats");
});
});
describe("unserved write plans", () => {
it("names a write to no state type and a tool with no successful call", () => {
expect(
+25
View File
@@ -320,6 +320,31 @@ export const QUERY_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/books?shelf=a", [SHELVED_FIRST, SHELVED_THIRD]],
];
/** A clock spelled with a `+00:00` offset where the books application's item reads spell `Z`. */
export const offsetClock = (clock: JsonValue | undefined): string =>
String(clock).replace(/Z$/u, "+00:00");
/** A book as a list that spells its clocks with an offset shows it. */
export const offsetListed = (entity: Book): Book => ({
...entity,
created_at: offsetClock(entity.created_at),
updated_at: offsetClock(entity.updated_at),
});
/** Item reads spell clocks with `Z`, the whole list with `+00:00`; a create is read back both ways. */
export const OFFSET_LIST_ROWS: readonly TimedCaptureRow[] = [
["GET", "/api/books", [FIRST_BOOK, SECOND_BOOK].map(offsetListed)],
["GET", "/api/books/1", FIRST_BOOK],
["GET", "/api/books/2", SECOND_BOOK],
["POST", "/api/books", THIRD_BOOK, 201, { name: "Third Book" }],
["GET", "/api/books/3", THIRD_BOOK],
[
"GET",
"/api/books",
[FIRST_BOOK, SECOND_BOOK, THIRD_BOOK].map(offsetListed),
],
];
/** The end of the last exchange of BOOK_ROWS, to the next whole second: where the served clock starts. */
export const BOOK_WRITE_CLOCK = "2026-10-17T18:00:12.000Z";