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:
1 parent
71dc6fe5bd
commit
957bcfa149
16 files changed
+619
-44
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user