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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/cli-duplicates-holder-createdat-canonical.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/cli": patch
---

fix(cli): `os migrate duplicates` reports every holder's `createdAt` as canonical ISO-8601 UTC on every dialect (#13999)

`DuplicateHolder.createdAt` is declared `string | null`, and the holder mapper
built it with `String(row.created_at)`. `created_at` is a **builtin audit
column** — not in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only
inside its `if (this.isSqlite)` arm — and the holder probe reads through the
raw-SQL seam, so no presentation runs on this path at all. The dialect therefore
decided what the operator saw.

On **Postgres and MySQL**, `created_at` materialises as a JS `Date`, so
`String()` ran `Date.prototype.toString`:

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

where the same command against **SQLite** printed `2026-08-30T10:19:25.947Z`.
One instant, two spellings, chosen by the dialect: the operator's local zone
baked in, whole seconds instead of milliseconds, no `Z`, and not
`Date.parse`-safe for anything consuming this command's JSON.

## What changes for a consumer

`duplicates[].holders[].createdAt` in the `os migrate duplicates` JSON now
carries canonical ISO-8601 UTC (`…Z`, milliseconds) on **every** dialect. On
SQLite the value is byte-identical to what it was — that side was already
canonical, which is why every existing pin on this command was green through the
defect. On Postgres and MySQL the value changes from the `Date.toString()`
rendering to the ISO spelling the field has always declared; a consumer that was
parsing the old rendering was parsing a zone-dependent, millisecond-lossy string.

Unchanged on purpose: a holder whose object carries no `created_at` column still
reports `createdAt: null` (the probe's `withCreatedAt: false` retry), and a
`Date` carrying no time value keeps its verbatim rendering rather than throwing
`RangeError` out of a read-only report.

## Where the repair lands, and where it deliberately does not

At the **mapper**. The CLI is a leaf consumer with a declared `string | null`, so
it is the side that owes the canonical spelling; the form follows the
`occurredAt` mapper already in `packages/metadata-protocol/src/protocol.ts`.

Not on the producer side: giving the driver one presented shape per dialect at
the read door would repair this site for free, but it reverses a deliberate
driver decision (`SqlDriver.withPostgresCalendarDayAsText`) and is a maintainer
call on the #13973 census as a whole. Zero driver files are touched here. And not
a `??` fallback — per #13973's standing prohibition the question is which side
owes the canonical spelling, and a tolerant fallback answers it by hiding it.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#13999] `os migrate duplicates` reports ONE `createdAt` spelling, on every dialect.
*
* ## The defect, and why every existing pin was green through it
*
* `DuplicateHolder.createdAt` is declared `string | null`, and the mapper built
* it with `String(row.created_at)`. `created_at` is a BUILTIN audit column — not
* in `datetimeFields`, and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm — and the holder probe reads through the raw-SQL seam,
* so no presentation runs on this path at all. The dialect therefore decides what
* arrives:
*
* - **Postgres / MySQL** materialise a JS `Date`, so `String()` ran
* `Date.prototype.toString`: `Sun Aug 30 2026 18:19:25 GMT+0800 (China
* Standard Time)` — the operator's local zone baked in, whole seconds instead
* of milliseconds, no `Z`, and not `Date.parse`-safe for anything consuming
* this command's JSON.
* - **SQLite** and its siblings hand back canonical ISO-8601 UTC text, which
* `String()` passes through untouched.
*
* Every pin this command already has drives SQLite (`duplicates.contract.test.ts`
* asserts the holder document against a real better-sqlite3 fixture), which is
* exactly the side that was already correct. That is why this file exists and why
* its whole point is to DISTINGUISH the two dialects rather than re-assert one:
* a test exercising only SQLite proves nothing about the defect.
*
* ## Which half rests on which evidence
*
* §A2 is a real dialect measurement — a live better-sqlite3 database, the real
* probes, the real collector. §A1 is the other side, and no runner here hosts a
* Postgres or a MySQL, so it drives the materialisation those dialects produce
* through a hand-built seam double. That `Date` is not this file's claim to make:
* it is the fact pinned, against live servers, in
* `packages/drivers/driver-sql/src/sql-driver-13567-audit-stamp-materialisation.test.ts`
* (read for this card, deliberately neither duplicated nor edited here — and the
* layering runs the same way `@objectstack/metadata-protocol`'s own OCC suite is
* argued: the consumer's seam is measured with the producer's measured value).
*
* §A3 is the assertion that actually states the contract — the two legs agree —
* and §A4 keeps the whole file non-vacuous by measuring what the removed
* expression really produced.
*
* ⛔ Not a `??` fallback and not a driver change: `withPostgresCalendarDayAsText`
* is a deliberate driver decision and is untouched. The CLI is a leaf consumer
* with a declared `string | null`, so the canonical spelling is owed here.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SqlDriver } from '@objectstack/driver-sql';
import {
normalizeRows,
GLOBAL_TENANT,
ORGANIZATION_FIELD,
SEQUENCES_TABLE,
type SeedTenancyExec,
} from '@objectstack/metadata-protocol';
import {
canonicalHolderCreatedAt,
collectDuplicateIdentifierReport,
type DuplicateHolder,
} from './duplicates.js';

/** The instant from the #13567 production report, kept verbatim. */
const GLOBAL_INSTANT = '2026-08-30T10:19:25.947Z';
/** A second instant, so the mapper is measured per row rather than against a constant. */
const ORG_INSTANT = '2026-02-01T00:00:00.001Z';

/**
* The zone the incident was observed in, FORCED rather than required.
*
* Test Core runs at UTC and `Temporal Conformance` at `America/New_York`, so the
* operator-facing symptom in §A4 would otherwise be spelled differently on every
* runner. Forcing it also makes §A1 mean what it says: the canonical spelling it
* asserts is produced while the process is demonstrably NOT at UTC.
*/
const INCIDENT_ZONE = 'Asia/Shanghai';

/**
* Run `body` with the process pinned to `tz`, then restore.
*
* Restoring rather than assuming matters because vitest reuses a worker across
* files — a leaked `TZ` would silently re-zone whatever runs next in this
* process. (A sibling copy lives in the driver-sql pin above; a zone-scoping
* utility is not a guard that could weaken in one copy and nowhere else, so the
* two are deliberately independent rather than shared across a package boundary.)
*/
async function underProcessZone<T>(tz: string, body: () => Promise<T> | T): Promise<T> {
const previous = process.env.TZ;
process.env.TZ = tz;
try {
return await body();
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}

/** The registry view the booted stack hands the command. */
const CASE_ONLY = [{ name: 'crm_case', fields: { case_number: { type: 'autonumber' } } }];
const CASE_AND_TICKET = [
...CASE_ONLY,
// No `created_at`: an object that opted out of system fields still has to
// produce holders, with a null timestamp rather than a failed probe.
{ name: 'crm_ticket', fields: { ticket_number: { type: 'autonumber' } } },
];

const collect = (exec: SeedTenancyExec, objects: unknown[]) =>
collectDuplicateIdentifierReport({
exec,
normalize: normalizeRows,
objects,
database: 'fixture',
globalTenant: GLOBAL_TENANT,
organizationField: ORGANIZATION_FIELD,
sequencesTable: SEQUENCES_TABLE,
client: 'better-sqlite3',
now: () => new Date(GLOBAL_INSTANT),
// This file measures the holder mapper and nothing else; the pre-flight
// section has its own pins over its own fixtures in the contract test.
runtimeIndexPreflight: [],
});

const holdersOf = (duplicates: Array<{ holders: DuplicateHolder[] }>): DuplicateHolder[] =>
duplicates.flatMap((d) => d.holders);

// ── §A1's seam: the value the live dialects put on the wire ─────────────────

/**
* A raw-SQL seam that answers the holder probe with `created_at` materialised the
* way Postgres and MySQL materialise it — a JS `Date`.
*
* Dispatches on the probes' own aliases: `AS holder_id` is unique to the holder
* statement, `AS dup_value` is then the duplicate statement, and the counter
* table simply does not exist in this fixture (a `__global__` counter beside an
* organization-scoped one is #8928's live CONDITION, a different section of the
* report and not this card's).
*/
function liveDialectSeam(globalStamp: unknown, orgStamp: unknown): SeedTenancyExec {
return async (sql: string) => {
if (sql.includes('AS holder_id')) {
return [
{ holder_id: 's1', dup_value: 'CASE-00001', organization: null, created_at: globalStamp },
{ holder_id: 'a1', dup_value: 'CASE-00001', organization: 'org_x', created_at: orgStamp },
];
}
if (sql.includes('AS dup_value')) {
return [{ dup_value: 'CASE-00001', holder_count: 2, partition_count: 2 }];
}
throw new Error(`no such table: ${SEQUENCES_TABLE}`);
};
}

// ── §A2's fixture: a real SQLite database, the real probes ──────────────────

let dir: string;
let driver: SqlDriver;
let sqliteExec: SeedTenancyExec;

beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'os-13999-'));
mkdirSync(join(dir, 'data'), { recursive: true });
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: join(dir, 'data', 'app.db') },
useNullAsDefault: true,
});
// The same wrapper `resolveSeedTenancyExec` builds around a driver exposing
// `execute(sql, params)`.
sqliteExec = (sql: string, params?: unknown[]) => driver.execute(sql, (params ?? []) as any[]);
const k = (driver as any).knex;

await k.schema.createTable('crm_case', (t: any) => {
t.string('id').primary();
t.timestamp('created_at');
t.string('organization_id');
t.string('case_number');
});
await k('crm_case').insert([
{ id: 's1', created_at: GLOBAL_INSTANT, organization_id: null, case_number: 'CASE-00001' },
{ id: 'a1', created_at: ORG_INSTANT, organization_id: 'org_x', case_number: 'CASE-00001' },
]);

await k.schema.createTable('crm_ticket', (t: any) => {
t.string('id').primary();
t.string('organization_id');
t.string('ticket_number');
});
await k('crm_ticket').insert([
{ id: 't1', organization_id: null, ticket_number: 'TKT-1' },
{ id: 't2', organization_id: 'org_x', ticket_number: 'TKT-1' },
]);
});

afterAll(async () => {
try { await driver.disconnect(); } catch { /* already down */ }
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
});

describe('#13999 §A — one instant, two dialect materialisations, one reported spelling', () => {
it('§A1 Postgres/MySQL hand a JS `Date`; the report carries canonical ISO-Z', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () =>
collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY),
);
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A2 SQLite hands canonical ISO-Z text; a real database, unchanged through the mapper', async () => {
const produced = await underProcessZone(INCIDENT_ZONE, () => collect(sqliteExec, CASE_ONLY));
expect(holdersOf(produced.duplicates)).toEqual([
{ id: 's1', organization: null, partition: GLOBAL_TENANT, createdAt: GLOBAL_INSTANT },
{ id: 'a1', organization: 'org_x', partition: 'org_x', createdAt: ORG_INSTANT },
]);
});

it('§A3 the two dialects agree — the operator reads one document, not two', async () => {
const live = await collect(liveDialectSeam(new Date(GLOBAL_INSTANT), new Date(ORG_INSTANT)), CASE_ONLY);
const sqlite = await collect(sqliteExec, CASE_ONLY);
expect(holdersOf(live.duplicates)).toEqual(holdersOf(sqlite.duplicates));
// And what they agree ON is machine-readable, which is the point of the
// command's JSON: every spelling re-parses to the instant it came from.
for (const holder of holdersOf(live.duplicates)) {
expect(new Date(holder.createdAt as string).toISOString()).toBe(holder.createdAt);
}
});

it('§A4 non-vacuity: `String(row.created_at)` really did produce a different document', async () => {
// What the removed expression shipped on the production default driver.
const spelled = await underProcessZone(INCIDENT_ZONE, () => String(new Date(GLOBAL_INSTANT)));
expect(spelled.startsWith('Sun Aug 30 2026 18:19:25 GMT+0800')).toBe(true);
expect(spelled).not.toBe(GLOBAL_INSTANT);
// Whole seconds: the milliseconds are not merely re-spelled, they are gone,
// so this was lossy and not only unsightly.
expect(new Date(spelled).toISOString()).toBe('2026-08-30T10:19:25.000Z');
// And the SQLite side of the same run was already canonical — which is how
// the split survived: one dialect's output was never wrong.
expect(String(GLOBAL_INSTANT)).toBe(GLOBAL_INSTANT);
});
});

describe('#13999 §B — the arms that were not broken', () => {
it('§B1 an object with no `created_at` column still reports holders, with `createdAt: null`', async () => {
const produced = await collect(sqliteExec, CASE_AND_TICKET);
const tickets = produced.duplicates.filter((d) => d.object === 'crm_ticket');
expect(tickets).toHaveLength(1);
expect(tickets[0].holders.map((h) => h.createdAt)).toEqual([null, null]);
// Through the real retry, not a shortcut: the `withCreatedAt: true` probe
// fails on this table and the collector re-asks without the column.
expect(produced.skipped.some((s) => s.object === 'crm_ticket')).toBe(false);
});

it('§B2 a `Date` carrying no time value keeps its verbatim rendering instead of throwing', () => {
// `mysql2` hands one back for a zero date, and `toISOString()` throws on it.
// A non-instant has no canonical spelling; a spelling defect in a report must
// not become a crashed migration command.
const invalid = new Date(Number.NaN);
expect(() => invalid.toISOString()).toThrow(RangeError);
expect(canonicalHolderCreatedAt(invalid)).toBe(String(invalid));
expect(canonicalHolderCreatedAt(null)).toBeNull();
expect(canonicalHolderCreatedAt(undefined)).toBeNull();
});
});
Loading
Loading