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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
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
32 changes: 32 additions & 0 deletions .changeset/migration-timestamp-canonicalisation.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/metadata": patch
---

fix(metadata): `migrateSysNotificationToEvent` writes canonical ISO timestamps, not `Date.prototype.toString` (#13998)

`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute` — a door that does not run `formatOutput`, so none of its
repairs apply. On SQLite the legacy stamps come back as canonical ISO text and
`String(row.created_at)` is the identity. On Postgres and MySQL an instant
column materialises as a JS `Date`, so the migration wrote

```
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
```

into `created_at` on the new `sys_inbox_message` row and into `created_at` / `at`
on the new `sys_notification_receipt` row — whole seconds in the **migrating
host's** zone with the milliseconds dropped, or a value no dialect's timestamp
grammar accepts at all. The migration is one-way, so that spelling is what the
platform would carry afterwards.

Both stamps are now canonicalised at the migration, matching the repo's existing
correct form: a `Date` is rendered with `toISOString()`, ISO text passes through
untouched. Neither column could be repaired further upstream — `created_at` is a
builtin audit column that `formatOutput` repairs only in its `if (this.isSqlite)`
arm, and `read_at` is a legacy column ADR-0030 removed from the object, so it is
not a declared `Field.datetime` either and no coercion could ever reach it.

Pinned with a hand-made `Date` driven through the migration's read path under a
forced process zone, which is what breaks the SQLite identity that kept the
existing cases green while the defect was live.
Original file line numberDiff line numberDiff line change
Expand Up@@ -152,3 +152,134 @@ describe('migrateSysNotificationToEvent', () => {
expect(result.error).toContain('.raw');
});
});

// ---------------------------------------------------------------------------
// [#13998] What this migration WRITES, when the legacy row hands out a `Date`.
//
// `selectLegacyRows` reads through `driver.raw`/`execute` — a door that does
// not run `formatOutput`, so none of its repairs apply. On SQLite the legacy
// stamps come back as canonical ISO TEXT and `String(row.created_at)` is the
// IDENTITY, which is why every case above stayed green while the defect was
// live. On Postgres and MySQL an instant column materialises as a JS `Date`
// (pinned in `driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`),
// and this migration is one-way: whatever spelling lands is what the platform
// carries afterwards.
//
// `@objectstack/metadata` has no driver dependency and must not grow one — the
// layering runs the other way — so, exactly like the OCC seam's own regression
// suite, the discriminating input here is a HAND-MADE `Date`. That is the whole
// point of these cases: they break the SQLite identity the cases above rely on.
// ---------------------------------------------------------------------------

/** The instant from the production report, kept verbatim (#13567 / #13382). */
const REPORTED_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the receipt's `at` cannot pass by matching `created_at`. */
const REPORTED_READ_INSTANT = '2026-08-31T02:03:04.567Z';

/** Canonical audit-timestamp text — what SQLite stores and what must be written. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Forced rather than required so these cases are non-vacuous on any runner:
* Test Core runs at UTC, a developer runs at whatever their laptop is set to.
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. Mirrors `underProcessZone` in the driver-side pin.
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

describe('#13998 the timestamp spelling written into the new rows', () => {
it('control — `String(Date)` is NOT the canonical spelling (the input discriminates)', async () => {
const value = new Date(REPORTED_INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const spelled = await underProcessZone('Asia/Shanghai', () => String(value));
// The prefix only: the trailing `(China Standard Time)` is the one
// implementation-defined part of `toString`.
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
// Whole seconds in the PROCESS zone: the milliseconds are gone.
expect(Date.parse(spelled)).toBe(value.getTime() - value.getMilliseconds());
expect(spelled).not.toBe(REPORTED_INSTANT);
expect(spelled).not.toMatch(ISO_Z);
// …and the canonical rendering of the same instant is zone-independent.
expect(value.toISOString()).toBe(REPORTED_INSTANT);
});

it('canonicalises a `Date` created_at/read_at into ISO on inbox, receipt and receipt.at', async () => {
const createdAt = new Date(REPORTED_INSTANT);
const readAt = new Date(REPORTED_READ_INSTANT);
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'You were mentioned',
body: 'hi', url: '/x', actor_name: 'Ada', is_read: 1,
// The discriminating input: what Postgres/MySQL actually hand out.
read_at: readAt, created_at: createdAt, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await underProcessZone('Asia/Shanghai', () =>
migrateSysNotificationToEvent({ driver: d.driver, data: e.engine }));

expect(result.status).toBe('migrated');
expect(result.migrated).toBe(1);

const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;

// Every written stamp is canonical ISO-Z text — not a `Date`, and not a
// `Date.prototype.toString` rendering carrying the migrating host's zone.
for (const [where, written] of [
['inbox.created_at', inbox.row.created_at],
['receipt.created_at', receipt.row.created_at],
['receipt.at', receipt.row.at],
] as const) {
expect(typeof written, `${where} must be written as text`).toBe('string');
expect(written as string, `${where} must be canonical ISO-Z`).toMatch(ISO_Z);
// The zone the OLD spelling would have baked in is absent.
expect(written as string, `${where} must not carry a GMT offset`).not.toContain('GMT');
}

// The instants themselves are preserved to the millisecond — the half
// `String(Date)` silently dropped.
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
// …and `at` is the READ stamp, not `created_at` echoed back.
expect(receipt.row.at).not.toBe(receipt.row.created_at);

// The zone was restored rather than leaked into whatever runs next.
expect(process.env.TZ).not.toBe('Asia/Shanghai');
});

it('leaves canonical ISO text exactly as it found it (the SQLite path is unchanged)', async () => {
const d = fakeDriver([
{
id: 'n1', recipient_id: 'u1', type: 'mention', title: 'hi', body: null,
url: null, actor_name: null, is_read: 1, read_at: REPORTED_READ_INSTANT,
created_at: REPORTED_INSTANT, organization_id: 'org_1',
},
]);
const e = fakeEngine();

const result = await migrateSysNotificationToEvent({ driver: d.driver, data: e.engine });

expect(result.status).toBe('migrated');
const inbox = e.inserts.find((i) => i.object === 'sys_inbox_message')!;
const receipt = e.inserts.find((i) => i.object === 'sys_notification_receipt')!;
expect(inbox.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.created_at).toBe(REPORTED_INSTANT);
expect(receipt.row.at).toBe(REPORTED_READ_INSTANT);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -100,7 +100,7 @@ export async function migrateSysNotificationToEvent(
const recipientId = row.recipient_id != null ? String(row.recipient_id) : null;
if (!recipientId) continue; // defensive — guarded by the SELECT filter
const orgId = row.organization_id != null ? String(row.organization_id) : null;
const createdAt = row.created_at != null ? String(row.created_at) : now();
const createdAt = row.created_at != null ? canonicalTimestampText(row.created_at) : now();
const title = row.title != null ? String(row.title) : (row.type != null ? String(row.type) : 'Notification');
const isRead = row.is_read === true || row.is_read === 1 || row.is_read === '1';
// One topic for both the inbox row and the rewritten event, so the
Expand DownExpand Up@@ -128,7 +128,7 @@ export async function migrateSysNotificationToEvent(
user_id: recipientId,
channel: 'inbox',
state: isRead ? 'read' : 'delivered',
at: isRead && row.read_at != null ? String(row.read_at) : createdAt,
at: isRead && row.read_at != null ? canonicalTimestampText(row.read_at) : createdAt,
organization_id: orgId,
created_at: createdAt,
});
Expand DownExpand Up@@ -170,6 +170,52 @@ export async function migrateSysNotificationToEvent(
// Internal helpers
// ---------------------------------------------------------------------------

/**
* The canonical text spelling of a timestamp read back out of the legacy table.
*
* `selectLegacyRows` reads through `driver.raw`/`execute`, which hands the
* dialect client's own materialisation straight back — that door does not run
* `formatOutput`, so none of its repairs apply here on any dialect:
*
* - `created_at` is a BUILTIN audit column, so it is never in `datetimeFields`
* and no declared-field coercion reaches it; `formatOutput` repairs it only
* inside its `if (this.isSqlite)` arm (`repairNaiveUtcAuditTimestamp` over
* `AUDIT_TIMESTAMP_COLUMNS`).
* - `read_at` is a LEGACY column ADR-0030 removed from the object, so it is
* not declared either — it can never enter `datetimeFields`, and it is not
* an audit column, so no arm of `formatOutput` could reach it even at the
* record read door.
*
* On SQLite both arrive as canonical ISO text and `String()` is the identity —
* which is why every test in this directory stayed green. On Postgres and
* MySQL an instant column materialises as a JS `Date`
* (`withPostgresCalendarDayAsText` leaves the instant types alone deliberately;
* pinned in `sql-driver-13567-audit-stamp-materialisation.test.ts`), and
* `String(date)` spells
*
* Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)
*
* — whole seconds in the MIGRATING HOST's zone, with the milliseconds gone.
* This migration is one-way and this value is WRITTEN, so that spelling is what
* the platform would carry afterwards: either accepted and stored skewed and
* de-precisioned, or rejected outright, since the trailing zone name is in no
* dialect's timestamp grammar (#13998).
*
* Canonicalising HERE, at the consumer that writes, is deliberate and is the
* only shape that could also repair an already-migrated deployment (#13973
* option A). It is not a tolerant alias: `Date` and ISO text are two
* materialisations of ONE instant, not two spellings of a key. Matches the
* repo's existing correct form at `metadata-protocol/src/protocol.ts` (the
* `occurred_at` read in `readMetadataAuditEvents`); anything that is neither a
* string nor a `Date` keeps its previous `String()` rendering unchanged rather
* than having a unit guessed for it on a one-way write path.
*/
function canonicalTimestampText(value: unknown): string {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return String(value);
}

async function selectLegacyRows(driver: any): Promise<any[]> {
const result: any[] = await driver.raw(
`SELECT id, recipient_id, type, title, body, url, actor_name, is_read, read_at, created_at, organization_id ` +
Expand Down
Loading