fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude
, '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

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes - #14024

Merged
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation
Sep 1, 2026
Merged

fix(metadata): canonicalise the timestamps migrateSysNotificationToEvent writes#14024
zhuangjianguo merged 1 commit into
mainfrom
claude/issue-13998-migration-timestamp-canonicalisation

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Part of #13998 — the code half only. The data half of that card (whether any deployment has already run this migration against Postgres or MySQL, and therefore whether a backfill is owed) is unanswered and is a maintainer floor, so this PR deliberately does not carry a closing keyword. Reading below.

All gates and tests quoted here were run at 3fc4e3bf24, which is this branch's head.

What was wrong

selectLegacyRows reads the legacy sys_notification table through driver.raw/execute, and the migration then wrote

constcreatedAt=row.created_at!=null ? String(row.created_at) : now();at: isRead&&row.read_at!=null ? String(row.read_at) : createdAt,

into created_at on the new sys_inbox_message row and into created_at / at on the new sys_notification_receipt row. On Postgres and MySQL an instant column materialises as a JS Date, so String(...) spells

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

— whole seconds in the migrating host's zone, milliseconds dropped, and a trailing zone name that is in no dialect's timestamp grammar. This migration is one-way, so that spelling is what the platform carries afterwards.

The fix

One canonicaliser, applied at both sites, matching the repo's existing correct form (metadata-protocol/src/protocol.ts, the occurred_at read in readMetadataAuditEvents):

functioncanonicalTimestampText(value: unknown): string{if(typeofvalue==='string')returnvalue;if(valueinstanceofDate)returnvalue.toISOString();returnString(value);}

Route A of #13973 (canonicalise at the migration), as triaged — not the driver-side read-door normalisation, and not a tolerant ?? fallback. A Date and ISO text are two materialisations of one instant, not two spellings of a key. 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; widening that would be a second, unevidenced decision.

Why neither column could be repaired further upstream

This read path bypasses formatOutput entirely — execute() returns await builder untouched, the same class of bypass sql-driver.ts:3964-3965 already names for aggregate and distinct. Even at the record read door the two columns diverge by different gates, and neither one closes:

  • 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 = ['created_at', 'updated_at'], sql-driver.ts:269).
  • read_at is a legacy column ADR-0030 removed from the object, so it is not a declared Field.datetime either — datetimeFields is built from declared datetime columns, and read_at appears nowhere in packages/platform-objects/src today. No arm of formatOutput could reach it on any dialect.

The pin, and why it is the real content here

The defect was invisible because this migration's tests drive SQLite/memory shapes, where the legacy stamps are already canonical ISO text and String(row.created_at) is the identity. The new cases break that identity by feeding a hand-made Date — the discriminating input — through the migration's read path under a forced process zone. @objectstack/metadata has no driver dependency and must not grow one, so a hand-made Date is the right instrument, exactly as the OCC seam's own suite uses one.

They live in the existing test file and reuse its already-pinned engine doubles, so check:engine-double-contract's ledger is untouched.

Ablation, on the committed tree, no rebuild leg (the pin imports ./migrate-sys-notification-to-event.js relatively, so vitest resolves source; dist/ is not on this path — stated rather than fabricated):

  • mutation proven on disk in both directions — injected String(row.created_at) / String(row.read_at) count 2, removed canonicalTimestampText(row. count 0 — and by blob: 1f9ef52ee8 to fb4534c3e5
  • result: Tests 1 failed | 7 passed (8), failing precisely on the new case with AssertionError: inbox.created_at must be canonical ISO-Z: expected 'Sun Aug 30 2026 18:19:25 GMT+0800 (Ch…' to match /^\d{4}-\d{2}-\d{2}T…/ — the production spelling, reproduced
  • the other 7 cases stayed green under the mutation, which is the SQLite identity demonstrated rather than asserted
  • restore under trap ... EXIT INT TERM with absolute paths, proven by an empty git diff HEAD (0 bytes from --name-only), a clean git status, and a restored blob matching the HEAD blob 1f9ef52ee8

Verification

  • pnpm --filter '@objectstack/metadata^...' buildVERDICT command-exit 0
  • pnpm --filter @objectstack/metadata testTest Files 38 passed (38), Tests 628 passed (628)
  • gate family derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 3fc4e3bf24 (34 commands, path-derived plus the test-file convention kind): 33 exit 0, 1 NOT MEASURED — node scripts/check-test-completeness.mjs exits 3 with no test-run log to read, which its own output states is not a red and not a finding
  • pnpm check:type-check-debt re-measured 29 ledger entries at this head and reports surplus: none — every entry sits exactly at its measurement, so any new error is red, so the new test code adds zero tsc errors to @objectstack/metadata's frozen 89
  • pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both needed a built workspace (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and were re-run green afterwards rather than left at their exit-3 prerequisite state

ESLint — a declared narrowing, measured on three counts rather than skipped. Run on the changed files only, --no-inline-config --format json: 0 errors, 0 warnings.

  1. The population is read from ESLint's own config, not guessed: --print-config resolves a real config for both .ts files (6 and 5 active rules), and ESLint itself reports the changeset as File ignored because no matching configuration was supplied.
  2. The file count is read from --format json: 3 result objects, 2 linted, 1 ignored.
  3. Untouched files cannot move: --print-config shows no parserOptions.project on either file, and eslint.config.mjs:328 records — measured with a positive control — that this repo never enables type-aware linting ... for ANY file. Every file's verdict therefore depends only on its own bytes and the shared config, and this diff changes neither the config nor any other file.

Readings the card asked for

  • Has any deployment already run this migration against PG/MySQL — the data half.Cannot be ruled out, so the data half stays open and this PR writes no backfill. The repo keeps no record that could answer it: the migration has no caller anywhere (no CLI command, no boot hook — docs/handoff/adr-0030-notification-convergence.md labels it "Data migration (not auto-run)" and hands operators a manual cut-over sequence), and it is not in the sys_migration ledger — the well-known ids are only adr-0104-file-references and adr-0104-value-shapes. Meanwhile it is published: migrateSysNotificationToEvent appears in seven package CHANGELOGs. What narrows the population is the finding below: no driver in this repo defines .raw, so the documented call errors out before touching data. That leaves only an operator who passed a knex-like handle of their own, which the repo cannot see either way.
  • Which failure shape occurs on a live server — accepted-and-skewed, or rejected mid-run.NOT MEASURED.OS_TEST_POSTGRES_URL and OS_TEST_MYSQL_URL are unset and nothing is listening in this container, so no live dialect was reachable. The fix is safe under either shape: it removes the input to both, since the value written is canonical ISO text on every dialect. The JavaScript half that needs no server — that String(Date) drops the milliseconds and bakes the process zone — is measured in the pin.
  • Does read_at share the defect, and by which gate. Yes, and by a different gate than created_at — the two bullets above. Both are covered by the fix.
  • Any sibling migration with the same shape. No. The card's expression over packages/metadata/src/migrations/ returns the two lines in this file and nothing else; widened to String\((row|r|legacy|old)\.[A-Za-z_]*_at\b across all of packages, the other hits are read-side sites already accounted for by the [finding] Sweep: which consumers compare or format a value whose runtime type differs between the Date-materialising drivers and the ISO-text ones #13973 census family (objectql/engine.ts, rest-server.ts, platform-objects/system/migration-flag.ts, metadata-protocol/protocol.ts, cli/commands/migrate/duplicates.ts) plus services/service-queue/src/db-queue-adapter.ts:262. The three other files in this migrations directory contain no _at reference at all.

Out-of-scope finding, filed not folded

#14023 — every migration in packages/metadata/src/migrations/ requires driver.raw(...), and no driver in this repo defines that method; they all expose execute(). Deduped against the backlog first. Different defect class, so it is not repaired here, and #14023 remains open on its own terms.

Generated by Claude Code


Generated by Claude Code

…vent` writes
`selectLegacyRows` reads the legacy `sys_notification` table through
`driver.raw`/`execute`, a door that does not run `formatOutput`. On SQLite the
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 a `Date.prototype.toString` rendering — whole seconds in
the migrating host's zone, milliseconds dropped — into the new inbox and
receipt rows. The migration is one-way.
Both `created_at` and `read_at` now go through one canonicaliser matching the
repo's existing correct form. Pinned with a hand-made `Date` under a forced
process zone, which breaks the SQLite identity that kept the existing cases
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37packageMentionDocs.

Which tree this was computed on

This run read content/docs from c98582dd0e0827bc597c495fd557b1dbd2730b24 — the merge of head 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b into base c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98582dd0e0827bc597c495fd557b1dbd2730b24 && git checkout c98582dd0e0827bc597c495fd557b1dbd2730b24
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b && git checkout -B drift-repro c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37 && git merge --no-ff 3fc4e3bf24a7bc75fdca1cbed7e36a9adc89a04b
node scripts/docs-audit/affected-docs.mjs --json c54d4d3d7d0a7f3e6ec848dd0cb415ee35ebbb37

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@zhuangjianguo@claude