Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/lucky-pugs-shave.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
---
'@objectstack/metadata-protocol': patch
'@objectstack/metadata': patch
---

Canonicalise driver-materialised timestamps at the metadata adapter boundaries

`MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp') and
`MetadataStats.mtime` is declared `z.string().datetime()`, but three producers
adapted a driver row into those declared types without converting the value.
`created_at` / `updated_at` are builtin audit columns and `recorded_at` is a
declared `Field.datetime`; `SqlDriver#formatOutput` repairs both only inside its
`if (this.isSqlite)` arm, so on Postgres and MySQL a JS `Date` landed in a field
every consumer reads as a `string`.

`SysMetadataRepository#get` / `#getByHash` and `DatabaseLoader#stat` now emit
canonical ISO-8601 text on every dialect, matching the sibling producers that
already spelled it correctly. Values that were already canonical (SQLite) pass
through byte-identically.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13997] `MetadataItem.authoredAt` is declared `z.string()` — the two
* adapter sites that pass a driver row straight through must canonicalise it.
*
* ## The defect
*
* `MetadataItem.authoredAt` is declared `z.string().describe('ISO-8601
* timestamp')` (`packages/metadata-core/src/types.ts`), and `MetadataItem` is
* a `z.infer`, so the field is `string` to every consumer. Two producers in
* this file adapted a driver row into that declared type WITHOUT converting
* the timestamp:
*
* - `getByHash()` — `recorded_at`, a declared `Field.datetime` on
* `sys_metadata_history`;
* - `rowToItem()` (reached by `get()`) — `updated_at` / `created_at`, the
* BUILTIN audit columns.
*
* On Postgres and MySQL both arrive out of the record read door as a JS
* `Date`: `SqlDriver#formatOutput` repairs the audit columns and folds
* declared `datetime` columns only inside its `if (this.isSqlite)` arm, and
* `withPostgresCalendarDayAsText` leaves `timestamptz` / `timestamp`
* deliberately untouched. That dialect fact is pinned live in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* ## Why nothing reported it, and what that costs THIS file
*
* Two independent reasons. `row` is `any`, so tsc saw a `string` assignment
* that never happened. And `MetadataItemSchema` — the runtime validator that
* would have caught it — is parsed nowhere on a production path: its only
* `.parse` call sites in the repo are its own unit test
* (`packages/metadata-core/test/types.test.ts`), which feeds a hand-made
* **string**.
*
* ⚠️ That is the trap this file exists to break. A fixture built from a
* hand-made string proves nothing here, because the value under test is
* already the declared shape before the adapter runs — the assertion and the
* input share an identity. **Every case below drives a hand-made `Date`**, the
* one shape the live dialects produce and no existing fixture ever did, and
* §A's non-vacuity guard asserts the input really is a `Date` before reading
* the output. Without that guard a fixture that silently degraded to a string
* would keep this file green while measuring nothing.
*
* ⛔ No driver dependency: `@objectstack/metadata-protocol` has none and must
* not grow one — the layering runs the other way. The `Date` is hand-made here
* for exactly the reason the #13567 pin states for the OCC seam next door.
*
* ## What is asserted
*
* The declared contract itself, via `MetadataItemSchema.safeParse` — not a
* hand-rolled regex standing in for it. This is the schema's first evaluation
* against a driver-shaped input in this repo; a bare `toThrow()` or a
* `typeof` check would each pass for reasons unrelated to the defect.
*/

import { describe, it, expect, beforeEach } from 'vitest';
// The producer's OWN write-verb dispatch decisions (#4550 delete / #5480
// update), so the fake engine below cannot accept a call ObjectQL refuses.
// Imported from `@objectstack/metadata-core`, not `@objectstack/objectql`:
// objectql depends on this package, so that import would close a cycle.
import {
assertEngineDeleteDispatch,
assertEngineUpdateDispatch,
assertEngineFindOnePredicate,
MetadataItemSchema,
} from '@objectstack/metadata-core';
import { SysMetadataRepository } from './sys-metadata-repository.js';

interface Row {
[k: string]: unknown;
}

/** 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');

/**
* Minimal engine fake. Deliberately stores exactly what it is handed — no key
* dropping, no coercion — so a `Date` planted in a row survives to the read
* door the way a live driver's would.
*/
function makeFakeEngine() {
const rows = new Map<string, Row>();
const historyRows: Row[] = [];

const keyOf = (w: Record<string, unknown>) =>
`${String(w.type)}|${String(w.name)}|${String(w.organization_id ?? 'null')}|${String(w.state ?? 'active')}`;

const findRow = (where: Record<string, unknown>) => {
if (where.id !== undefined) {
for (const [k, r] of rows) if (r.id === where.id) return { key: k, row: r };
return null;
}
const k = keyOf(where);
const r = rows.get(k);
return r ? { key: k, row: r } : null;
};

const matchesHistory = (h: Row, where: Record<string, unknown>): boolean =>
Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
return v === undefined || h[k] === v;
});

return {
rows,
historyRows,
async find(table: string, opts: { where: Record<string, unknown>; limit?: number }) {
const matched =
table === 'sys_metadata_history'
? historyRows.filter((h) => matchesHistory(h, opts.where))
: Array.from(rows.values()).filter((r) => {
if (opts.where.type && r.type !== opts.where.type) return false;
if (
opts.where.organization_id !== undefined &&
r.organization_id !== opts.where.organization_id
)
return false;
if (opts.where.state && r.state !== opts.where.state) return false;
return true;
});
// Hold the caller's bound, AFTER the filter and by PRESENCE — a double
// that silently ignores `limit` answers more rows than the real engine
// would, which is the shape `check:objectql-double-limit` exists to stop.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async findOne(table: string, opts: { where: Record<string, unknown> }) {
assertEngineFindOnePredicate(table, opts);
if (table === 'sys_metadata_history')
return historyRows.find((h) => matchesHistory(h, opts.where)) ?? null;
return findRow(opts.where)?.row ?? null;
},
async insert(table: string, data: Record<string, unknown>) {
if (table === 'sys_metadata_history') {
const h: Row = { ...data };
if (!h.id) h.id = `h_${historyRows.length + 1}`;
historyRows.push(h);
return { id: h.id as string };
}
const k = keyOf(data);
const row: Row = { id: `r_${rows.size + 1}`, ...data };
rows.set(k, row);
return { id: row.id as string };
},
async update(_t: string, data: Record<string, unknown>, opts: { where: Record<string, unknown> }) {
assertEngineUpdateDispatch(data, opts);
const found = findRow(opts.where);
if (!found) throw new Error('not found');
rows.set(found.key, { ...found.row, ...data });
return { id: found.row.id as string };
},
async delete(_t: string, opts: { where: Record<string, unknown> }) {
assertEngineDeleteDispatch(opts);
const found = findRow(opts.where);
if (!found) return { deleted: 0 };
rows.delete(found.key);
return { deleted: 1 };
},
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
return cb(undefined, { owned: true });
},
};
}

const view = (label: string) => ({
name: 'case_grid',
label,
object: 'case',
columns: [{ field: 'name' }],
});

describe('#13997 — authoredAt is canonical ISO-8601 text, whatever the dialect materialised', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let repo: SysMetadataRepository;
const ref = { org: 'org_alpha', type: 'view' as const, name: 'case_grid' };

beforeEach(() => {
engine = makeFakeEngine();
repo = new SysMetadataRepository({
engine,
organizationId: 'org_alpha',
orgLabel: 'org_alpha',
});
});

describe('§A get() — the builtin audit columns, via rowToItem', () => {
it('emits a canonical ISO string when the row carries a JS Date', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

// Restate the row the way Postgres/MySQL hand it out. Mutating the
// stored row rather than the returned copy is what makes the READ path
// — the adapter under test — see the `Date`.
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = PG_INSTANT;
stored.created_at = PG_INSTANT;

// Non-vacuity guard: if the fixture ever degrades to a string this file
// would keep passing while testing the shape that was never broken.
expect(stored.updated_at).toBeInstanceOf(Date);

const item = await repo.get(ref);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

// The declared contract itself, evaluated against a driver-shaped input.
const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});

it('passes an already-canonical SQLite string through byte-identically', async () => {
await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });

const canonical = '2026-03-04T05:06:07.089Z';
const stored = Array.from(engine.rows.values())[0]!;
stored.updated_at = canonical;

expect(typeof stored.updated_at).toBe('string');

const item = await repo.get(ref);
// Idempotent: the dialect that was already correct must not be reshaped.
expect(item!.authoredAt).toBe(canonical);
});
});

describe('§B getByHash() — recorded_at, a declared Field.datetime', () => {
it('emits a canonical ISO string when the history row carries a JS Date', async () => {
const put = await repo.put(ref, view('A'), { parentVersion: null, actor: 'usr_1' });
const hash = put.version;

const historyRow = engine.historyRows[0]!;
historyRow.recorded_at = PG_INSTANT;

// Same non-vacuity guard as §A, for the other column and the other door.
expect(historyRow.recorded_at).toBeInstanceOf(Date);

const item = await repo.getByHash(ref, hash);
expect(item).not.toBeNull();

expect(typeof item!.authoredAt).toBe('string');
expect(item!.authoredAt).toMatch(ISO_Z);
expect(item!.authoredAt).toBe(PG_INSTANT.toISOString());

const parsed = MetadataItemSchema.safeParse(item);
expect(parsed.success).toBe(true);
});
});
});
46 changes: 44 additions & 2 deletions packages/metadata-protocol/src/sys-metadata-repository.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,44 @@ import type { IObjectQLEngine } from '@objectstack/core';
// door too (that shared-rule argument is the module's whole reason to exist).
import { isWritablePackage } from './package-writability.js';

/**
* Canonicalise a driver-materialised timestamp into the ISO-8601 string the
* declared output type of this adapter promises.
*
* [#13997] `sys_metadata`'s `created_at` / `updated_at` are BUILTIN audit
* columns; `sys_metadata_history`'s `recorded_at` is a declared
* `Field.datetime`. On the live dialects BOTH arrive out of the record read
* door as a JS `Date`: `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, a `Date` is the right
* materialisation for them, and `Field.datetime` depends on it". Pinned in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`.
*
* `MetadataItem.authoredAt` is declared `z.string()` ('ISO-8601 timestamp',
* `packages/metadata-core/src/types.ts`) and `MetadataItem` is a `z.infer`, so
* the field is `string` to every consumer. The producer owes the canonical
* spelling, and this is the adapter boundary that asserts the declared type —
* hence here, and not at the driver's read door (which would reverse that
* deliberate driver decision and belongs to the whole census, not this fix).
*
* ⛔ NOT a tolerant fallback: it does not teach a consumer to accept an
* off-spec shape. It converts the one per-dialect materialisation the driver
* genuinely produces into the single declared spelling, at the producer. The
* `Date` arm is the SAME spelling `auditMetaItem` already applies to
* `sys_metadata_audit.occurred_at` in `protocol.ts` — one shape, not a third.
*
* Absent column -> `undefined`, so each caller's existing `?? <default>` chain
* keeps exactly its current meaning.
*/
function canonicalIsoInstant(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
return String(value);
}

/**
* Overlay-row lifecycle state.
*
Expand DownExpand Up@@ -414,7 +452,9 @@ export class SysMetadataRepository implements MetadataRepository {
// that as the string 'unknown' invents an identity the column never
// held, which is the same declared-≠-actual defect on the read side.
authoredBy: ((row as any).recorded_by as string | null | undefined) ?? null,
authoredAt: (row as any).recorded_at ?? new Date(0).toISOString(),
// [#13997] `recorded_at` is a declared `Field.datetime`, so on Postgres
// and MySQL it materialises as a JS `Date` — see `canonicalIsoInstant`.
authoredAt: canonicalIsoInstant((row as any).recorded_at) ?? new Date(0).toISOString(),
message: (row as any).change_note ?? undefined,
seq: ((row as any).event_seq as number) ?? 0,
};
Expand DownExpand Up@@ -1749,7 +1789,9 @@ export class SysMetadataRepository implements MetadataRepository {
// #4556 — `updated_by` / `created_by` are lookup('sys_user') too;
// absent means absent, not a user called 'unknown'.
authoredBy: (row.updated_by as string | null | undefined) ?? (row.created_by as string | null | undefined) ?? null,
authoredAt: row.updated_at ?? row.created_at ?? new Date().toISOString(),
// [#13997] The builtin audit columns materialise as a JS `Date` on the
// live dialects; `authoredAt` is declared `z.string()`.
authoredAt: canonicalIsoInstant(row.updated_at ?? row.created_at) ?? new Date().toISOString(),
message: undefined,
seq: this.seqCounter,
};
Expand Down
Loading
Loading