From 810751c3cfbc009cc5720ff9733b4c38f9dedac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:01:31 +0000 Subject: [PATCH 1/2] fix(metadata-protocol): canonicalise listDrafts updatedAt at the adapter boundary `SysMetadataRepository.listDrafts` declares `updatedAt: string | null` on an inline TypeScript return type and reached the field through `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so the JS `Date` that Postgres and MySQL materialise for the builtin audit columns walked straight past it into a field declared a string. Route the value through the file's existing `canonicalIsoInstant`, the same producer-side canonicalisation `rowToItem` applies, with the terminal chosen per call site: `null` here, because the chain being replaced already ended in `?? null` and that is what "absent" already means to this projection's consumers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...itory-14938-list-drafts-updated-at.test.ts | 339 ++++++++++++++++++ .../src/sys-metadata-repository.ts | 27 +- 2 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts diff --git a/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts new file mode 100644 index 0000000000..21a9fb142c --- /dev/null +++ b/packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14938] `SysMetadataRepository.listDrafts` declares `updatedAt: string | + * null` and used to emit the raw `updated_at` column, so on Postgres and MySQL + * it handed a JS `Date` through a field its own signature calls a string. + * + * ## The defect + * + * `listDrafts`' return type is an INLINE TypeScript object type on the method + * itself — not a Zod schema — and the projection reached the field through + * `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so a + * `Date` walks straight past it into the declared field. + * + * Two independent reasons nothing reported it, and both are why the site + * survived the #13973 census twice: a schema search finds no schema (the + * declaration is an inline return type), and `rows` is cast `as any[]` one line + * above the map, so tsc sees a `string` assignment that never happened. + * + * ## Why the value is a `Date` on the live dialects + * + * `updated_at` / `created_at` are the BUILTIN audit columns on `sys_metadata` + * (`Field.datetime`, `packages/metadata-core/src/objects/sys-metadata.object.ts`). + * `SqlDriver#formatOutput` repairs the audit columns + * (`repairNaiveUtcAuditTimestamp`) and folds the declared datetime columns + * (`normalizeSqliteDatetimeOutput`) ONLY inside its `if (this.isSqlite)` arm, + * and `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp` + * deliberately untouched because those are instants. That dialect fact is + * pinned live in + * `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`; + * this file does not re-derive it and takes on no driver dependency + * (`@objectstack/metadata-protocol` has none, and the layering runs the other + * way) — the `Date` is hand-made here for exactly that reason. + * + * ## What is asserted, and why it is not a hand-copied shape + * + * The conformance table below is keyed by `keyof DraftHeader`, where + * `DraftHeader` is extracted from the method's own signature with + * `Awaited>[number]`. So the assertion reads the DECLARATION + * under test rather than a second copy of it: a field added to or removed from + * that inline type reddens this file at type-check time instead of silently + * going unchecked. + * + * ⚠️ Every case drives a hand-made `Date` — the one shape the live dialects + * produce and no existing fixture ever did — and each case guards + * non-vacuity (`toBeInstanceOf(Date)`) on the seeded row BEFORE reading the + * output. Without that guard a fixture that degraded to a string would keep + * this file green while measuring nothing, because the input and the assertion + * would share an identity. + */ + +import { describe, it, expect } from 'vitest'; +// The engine-double contract gate: a fake looser than ObjectQL's own verb +// dispatch is how #4434 shipped a dead route with its suite green. +import { + assertEngineDeleteDispatch, + assertEngineUpdateDispatch, + assertEngineFindOnePredicate, +} from '@objectstack/metadata-core'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +interface Row { + [k: string]: unknown; +} + +/** + * The declared row shape, read off the method signature itself rather than + * restated. `CONFORMS` below is a mapped type over its keys, so this file + * cannot drift out of step with the declaration it pins. + */ +type DraftHeader = Awaited>[number]; + +/** Canonical instant text — exactly what `Date.prototype.toISOString` emits. */ +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +/** + * The instant every case drives, as the live dialects hand it out: a JS + * `Date`. Carries non-zero milliseconds on purpose — `String(date)` and + * `date.toString()` both drop them, so a truncating regression stays + * observable rather than coinciding with the canonical text. + */ +const PG_INSTANT = new Date('2026-03-04T05:06:07.089Z'); +const PG_CREATED = new Date('2026-01-02T03:04:05.006Z'); + +/** + * Reachable on BOTH live dialects (#14409): mysql2 3.23.1 answers a module + * constant literally named `INVALID_DATE` for a zero `DATETIME`, and + * postgres-date 1.0.7 builds `new Date(NaN)` for every year in 275760..294276 + * — years Postgres itself stores. + */ +const INVALID_INSTANT = new Date(NaN); + +/** + * Runtime conformance for the declared projection, keyed by the declaration's + * OWN keys. A `Date` reaching `updatedAt` satisfies neither arm of its union, + * which is precisely the defect. + */ +const CONFORMS: { [K in keyof DraftHeader]: (value: DraftHeader[K]) => boolean } = { + type: (v) => typeof v === 'string', + name: (v) => typeof v === 'string', + organizationId: (v) => v === null || typeof v === 'string', + packageId: (v) => v === null || typeof v === 'string', + updatedAt: (v) => v === null || typeof v === 'string', + updatedBy: (v) => v === null || typeof v === 'string', +}; + +/** + * Assert every field of every row against the declared union, naming the field + * in the assertion payload so a failure says WHICH one broke rather than + * `false !== true`. + */ +function expectConformsToDeclaration(rows: DraftHeader[]): void { + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + for (const key of Object.keys(CONFORMS) as Array) { + const check = CONFORMS[key] as (value: unknown) => boolean; + expect({ field: key, conforms: check(row[key]) }).toEqual({ field: key, conforms: true }); + } + } +} + +/** + * Minimal engine fake. Deliberately stores exactly what it is seeded with — no + * key dropping, no coercion — so a `Date` planted in a row survives to the read + * door the way a live driver's would. The `$or` arm is real because + * `listDrafts` issues one for a non-null-org caller (the ADR-0005 overlay + * reach), and an unimplemented combinator throws rather than silently reading + * a `$`-prefixed key as a field name. + */ +function makeFakeEngine(seed: Row[] = []) { + let nextId = 1; + const rows: Row[] = seed.map((r) => ({ id: `seed_${nextId++}`, ...r })); + const history: Row[] = []; + + const matches = (row: Row, where: Record): boolean => { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const branches = v as Array>; + if (!branches.some((b) => matches(row, b))) return false; + continue; + } + if (k.startsWith('$')) { + throw new Error(`fake matcher: unimplemented combinator ${k}`); + } + const rv = row[k] ?? null; + if ((v ?? null) !== rv) return false; + } + return true; + }; + + const tableOf = (name: string): Row[] => (name === 'sys_metadata' ? rows : history); + + return { + rows, + history, + async findOne(table: string, q: { where: Record }) { + assertEngineFindOnePredicate(table, q); + return tableOf(table).find((r) => matches(r, q.where)) ?? null; + }, + async find(table: string, q: { where: Record; limit?: number }) { + const matched = tableOf(table).filter((r) => matches(r, q.where)); + // Hold the caller's bound AFTER the filter and by PRESENCE — a double + // that ignores `limit` answers more rows than the real engine would. + return typeof q?.limit === 'number' ? matched.slice(0, q.limit) : matched; + }, + async insert(table: string, data: Row) { + const row = { id: `row_${nextId++}`, ...data }; + tableOf(table).push(row); + return row; + }, + async update(table: string, data: Row, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const row = tableOf(table).find((r) => matches(r, opts.where)); + if (row) Object.assign(row, data); + return row; + }, + async delete(_table: string, opts: { where?: Record }) { + assertEngineDeleteDispatch(opts); + /* not exercised here */ + }, + }; +} + +/** One env-wide draft row, seeded with whatever audit stamps a case drives. */ +const draftRow = (stamps: Row): Row => ({ + type: 'view', + name: 'case_grid', + organization_id: null, + state: 'draft', + package_id: null, + metadata: '{"label":"Cases"}', + checksum: 'sha-draft', + version: 1, + updated_by: 'usr_1', + created_by: 'usr_0', + ...stamps, +}); + +function makeRepo(engine: ReturnType, organizationId: string | null = null) { + return new SysMetadataRepository({ + engine: engine as never, + organizationId, + orgLabel: organizationId ?? 'env', + } as never); +} + +describe('#14938 — listDrafts emits canonical ISO text for updatedAt, whatever the dialect materialised', () => { + describe('§A updated_at as a JS Date — the Postgres/MySQL materialisation', () => { + it('canonicalises it and the whole projection conforms to the declared type', async () => { + const engine = makeFakeEngine([draftRow({ updated_at: PG_INSTANT, created_at: PG_CREATED })]); + const repo = makeRepo(engine); + + // Non-vacuity: if the fixture ever degrades to a string this file would + // keep passing while testing the shape that was never broken. + expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); + + const drafts = await repo.listDrafts(); + expect(drafts).toHaveLength(1); + expect(typeof drafts[0]!.updatedAt).toBe('string'); + expect(drafts[0]!.updatedAt).toMatch(ISO_Z); + expect(drafts[0]!.updatedAt).toBe(PG_INSTANT.toISOString()); + expectConformsToDeclaration(drafts); + }); + + it('reaches the same canonicalisation through the org-scoped $or read', async () => { + // A non-null-org caller sees BOTH its own overlay drafts and the env-wide + // ones (#3115); the canonicalisation must not depend on which arm matched. + const engine = makeFakeEngine([ + draftRow({ organization_id: 'org_alpha', updated_at: PG_INSTANT }), + draftRow({ name: 'lead_grid', updated_at: PG_INSTANT }), + ]); + const repo = makeRepo(engine, 'org_alpha'); + + expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); + + const drafts = await repo.listDrafts(); + expect(drafts).toHaveLength(2); + for (const draft of drafts) expect(draft.updatedAt).toBe(PG_INSTANT.toISOString()); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§B the created_at fallback — the second link of the same chain', () => { + it('canonicalises created_at when updated_at is absent', async () => { + const engine = makeFakeEngine([draftRow({ created_at: PG_CREATED })]); + const repo = makeRepo(engine); + + expect(engine.rows[0]!.updated_at).toBeUndefined(); + expect(engine.rows[0]!.created_at).toBeInstanceOf(Date); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.updatedAt).toBe(PG_CREATED.toISOString()); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§C SQLite — the dialect that was already correct is not reshaped', () => { + it('passes an already-canonical string through byte-identically', async () => { + const canonical = '2026-03-04T05:06:07.089Z'; + const engine = makeFakeEngine([draftRow({ updated_at: canonical })]); + const repo = makeRepo(engine); + + expect(typeof engine.rows[0]!.updated_at).toBe('string'); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.updatedAt).toBe(canonical); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§D the terminal this call site keeps', () => { + it('answers null — not a synthesised "now" — when both audit columns are absent', async () => { + // This projection declares `updatedAt: string | null` and the chain being + // replaced already ended in `?? null`, so `null` is what "absent" already + // means to every consumer of this list. `rowToItem` terminates in + // `?? new Date(...).toISOString()` instead; the terminal is chosen per + // call site (#14078), and substituting one for the other here would + // invent an edit instant for a row that never recorded one. + const engine = makeFakeEngine([draftRow({})]); + const repo = makeRepo(engine); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.updatedAt).toBeNull(); + expectConformsToDeclaration(drafts); + }); + + it('answers null for an Invalid Date rather than throwing or serving the text "Invalid Date"', async () => { + // The total `Date` arm (#14078, ruled B): unguarded, `toISOString()` + // raises `RangeError: Invalid time value`, and the spelling it replaced + // served the visible text instead. Here the shape takes the same branch + // an absent column takes. + const engine = makeFakeEngine([draftRow({ updated_at: INVALID_INSTANT })]); + const repo = makeRepo(engine); + + expect(engine.rows[0]!.updated_at).toBeInstanceOf(Date); + expect(Number.isNaN((engine.rows[0]!.updated_at as Date).getTime())).toBe(true); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.updatedAt).toBeNull(); + expect(drafts[0]!.updatedAt).not.toBe('Invalid Date'); + expectConformsToDeclaration(drafts); + }); + }); + + describe('§E updatedBy is deliberately NOT canonicalised — it is not a timestamp', () => { + it('passes the lookup column straight through, and the dialect asymmetry never reaches it', async () => { + // `updated_by` / `created_by` are `Field.lookup('sys_user')` on + // `sys_metadata` — string ids, not `Field.datetime`. The identical `??` + // shape on the next line is therefore correct as written; canonicalising + // it would be `String(value)` applied to a value that is already a + // string, and folding it into this fix would widen the card's scope to a + // line with no defect. + const engine = makeFakeEngine([draftRow({ updated_at: PG_INSTANT, updated_by: 'usr_7' })]); + const repo = makeRepo(engine); + + const drafts = await repo.listDrafts(); + expect(drafts[0]!.updatedBy).toBe('usr_7'); + expectConformsToDeclaration(drafts); + }); + + it('falls back to created_by and terminates in null, unchanged by this card', async () => { + const engine = makeFakeEngine([ + draftRow({ updated_at: PG_INSTANT, updated_by: undefined, created_by: 'usr_0' }), + draftRow({ + name: 'lead_grid', + updated_at: PG_INSTANT, + updated_by: undefined, + created_by: undefined, + }), + ]); + const repo = makeRepo(engine); + + const drafts = await repo.listDrafts(); + expect(drafts.find((d) => d.name === 'case_grid')!.updatedBy).toBe('usr_0'); + expect(drafts.find((d) => d.name === 'lead_grid')!.updatedBy).toBeNull(); + expectConformsToDeclaration(drafts); + }); + }); +}); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 49df62f1d2..201199a1a6 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -132,8 +132,9 @@ import { isWritablePackage } from './package-writability.js'; * `"Invalid Date"` instead. * * The terminal value is chosen **per call site**, and this one's is - * `undefined`: both callers (`getByHash` and `rowToItem`) already end in - * `?? new Date(...).toISOString()`, the branch an absent column takes today. + * `undefined`: every caller already carries such a chain — `getByHash` and + * `rowToItem` end in `?? new Date(...).toISOString()`, `listDrafts` (#14938) + * in `?? null` — the branch an absent column takes at each of them today. * The ruling assigns `undefined` exactly where "the field is optional and the * caller already carries a `?? default` chain". ⛔ NOT the visible text * `"Invalid Date"` — the fields fed from here are read by machines @@ -1175,7 +1176,27 @@ export class SysMetadataRepository implements MetadataRepository { name: row.name, organizationId: row.organization_id ?? null, packageId: row.package_id ?? null, - updatedAt: row.updated_at ?? row.created_at ?? null, + // [#14938] `updated_at` / `created_at` are the BUILTIN audit columns, + // so on Postgres and MySQL they arrive here as a JS `Date`: the audit + // repair and the declared-datetime fold both sit inside + // `SqlDriver#formatOutput`'s `if (this.isSqlite)` arm, and + // `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp` + // alone because those are instants. `rows` is cast `as any[]` above, + // so tsc never saw the `Date` land in a field this signature declares + // `string | null`. Canonicalised at the producer — the same adapter + // boundary `rowToItem` uses, never a tolerant `??` in the console or a + // reshape at the driver's read door (#13973's two standing + // prohibitions). + // + // The terminal is chosen PER CALL SITE (#14078) and this one is + // `null`, not `rowToItem`'s `?? new Date(...).toISOString()`: this + // projection declares `updatedAt: string | null` and the chain being + // replaced already ended in `?? null`, so `null` is what "absent" + // already means to every consumer of this list. An Invalid `Date` + // takes that same branch (the total `Date` arm), so a row the driver + // could not materialise reads as absent rather than as the visible + // text `"Invalid Date"`. + updatedAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? null, updatedBy: row.updated_by ?? row.created_by ?? null, })); } From 9d12b684ad46c3d5253cd8a8d2ed924f601b27d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 01:38:18 +0000 Subject: [PATCH 2/2] chore: pin the #14938 fake-engine double, and add the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:engine-double-contract requires every findOne/update/delete fake engine double in a test file to be registered in the pinned, shrink-only ledger. The new #14938 pin test carries one; `--write` recorded its three verb rows — 3 added or grown, 0 lost — and nothing else moved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .changeset/listdrafts-updated-at-canonical-iso.md | 13 +++++++++++++ scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/listdrafts-updated-at-canonical-iso.md diff --git a/.changeset/listdrafts-updated-at-canonical-iso.md b/.changeset/listdrafts-updated-at-canonical-iso.md new file mode 100644 index 0000000000..e41f028056 --- /dev/null +++ b/.changeset/listdrafts-updated-at-canonical-iso.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +`SysMetadataRepository.listDrafts` emits the ISO-8601 string its own signature declares for `updatedAt` + +`listDrafts` declares `updatedAt: string | null` on an inline TypeScript return type and reached the field through `row.updated_at ?? row.created_at ?? null`. `??` fires only on nullish, so the JS `Date` that Postgres and MySQL materialise for the builtin audit columns walked straight past it into a field the declaration calls a string. Driven through the published door, the pre-fix build answered `typeof "object"` and the visible text `Wed Mar 04 2026 05:06:07 GMT+0000 (Coordinated Universal Time)` where the same build's `dist/index.d.ts` promised `string | null`; it now answers `2026-03-04T05:06:07.089Z`. + +`updated_at` / `created_at` are builtin audit columns: `SqlDriver#formatOutput` repairs them (and folds declared `datetime` columns) only inside its `if (this.isSqlite)` arm, and `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp` deliberately untouched because those are instants. Nothing reported the mismatch — the declaration is an inline return type rather than a Zod schema, so a schema search finds nothing, and `rows` is cast `as any[]` one line above the map, so tsc saw a `string` assignment that never happened. + +Canonicalised at the producer through the same adapter boundary `rowToItem` already uses, with the terminal chosen per call site: `null` here, because the chain being replaced already ended in `?? null` and that is what "absent" already means to this projection's consumers. An Invalid `Date` — reachable on both live dialects — takes that same branch instead of raising. Already-canonical SQLite text passes through byte-identically, and `updatedBy` is unchanged: `updated_by` / `created_by` are `Field.lookup('sys_user')` string columns, which the dialect asymmetry never reaches. + +No published declaration moves: `dist/index.d.ts` and `dist/index.d.cts` are byte-identical across the fix, which already declared `updatedAt: string | null` before it. A JavaScript consumer that read the raw value and called a `Date` method on it, or stringified it, sees the corrected shape. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index e4c89c3fc4..913affa604 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1616,6 +1616,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/sys-metadata-repository-14938-list-drafts-updated-at.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/sys-metadata-repository.contract.test.ts", "verb": "delete",