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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/stranded-orphan-inventory-createdat-dialect.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/service-storage": patch
---

fix(service-storage): report `createdAt` on every stranded-orphan sample, not only on SQLite (#13996)

`inventoryStrandedFileOrphans` projects `created_at` out of the `sys_file` read
door and then tested it with `typeof row.created_at === 'string'`. `created_at`
is a BUILTIN audit column — it is not in `datetimeFields`, and
`SqlDriver#formatOutput` repairs the audit columns only inside its
`if (this.isSqlite)` arm — so that door hands the value back as canonical ISO-Z
text on SQLite and as a JS `Date` on Postgres and MySQL, the production default
drivers (pinned per dialect in driver-sql's
`sql-driver-13567-audit-stamp-materialisation.test.ts`).

The guard was therefore `false` for **every** row on both live dialects: a field
explicitly asked for from the driver was silently discarded, and every sample in
an operator's stranded-orphan report carried `createdAt: undefined` there while
looking correct on the SQLite the suite runs on.

The consumer now accepts both shapes and reports the canonical ISO-Z spelling —
the repair `@objectstack/metadata-protocol` already carries for `occurred_at`.
Normalising at the driver's read door instead would reverse the deliberate
`withPostgresCalendarDayAsText` decision that a `timestamptz` is an instant, so
the consumer owes the spelling.

No exported shape changes: `StrandedOrphanSample.createdAt` stays
`string | undefined`. An ISO string is still passed through byte-for-byte, and
an absent, null or unparseable stamp still reports `undefined` — never the
literal `"Invalid Date"` or `"undefined"` in the position an operator reads a
timestamp from. The sibling `key` / `name` guards are untouched: those are text
columns on every dialect, and only the timestamp straddles the divergence.
Original file line numberDiff line numberDiff line change
Expand Up@@ -505,3 +505,138 @@ describe("[#10950] the sweep cannot nominate a stranded orphan — the card's pr
expect(report.stranded).toBe(1);
});
});
// ── [#13996] `createdAt` across the dialect divergence ──────────────────────

/**
* What `samples[].createdAt` is, per runtime shape of `created_at`.
*
* ## Why this file could not have caught the defect before
*
* `created_at` is a BUILTIN audit column, so no declared-field coercion
* reaches it and `SqlDriver#formatOutput` repairs it only inside its
* `if (this.isSqlite)` arm. The record read door therefore hands it back as
* canonical ISO-Z TEXT on SQLite and as a JS `Date` on Postgres and MySQL —
* pinned at that door, per dialect, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B0/§B1).
*
* ⚠️ This repo's default test backend is SQLite, and every fixture above
* spells `created_at` as an ISO STRING — the one shape the old
* `typeof row.created_at === 'string'` guard accepted. So the guard dropped
* the field for every row on both production default drivers while every pin
* here stayed green. The discriminating input is a `Date`, and until this
* block nothing in this file produced one.
*
* ## What each case is worth as evidence
*
* - POSITIVE is the only case that changes verdict with the repair: red
* before it (`undefined`), green after.
* - CONTROL is a declared REGRESSION CONTROL and ⛔ NOT ablation evidence:
* it is green in both directions by construction, because the SQLite shape
* already worked. It exists to pin that the repair added an accepted shape
* and changed nothing about the one that already round-tripped.
* - REVERSE CONTROL guards the `String(…)` trap: the arm that makes the
* `occurred_at` shape work for a non-optional field would spell the literal
* `"undefined"` / `"Invalid Date"` into an operator's report here.
*/
describe('[#13996] `samples[].createdAt` — the driver-dependent runtime type of `created_at`', () => {
/** Canonical audit-timestamp text: what SQLite stores and `toISOString()` emits. */
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/** Sub-second digits are load-bearing — `String(Date)` drops exactly these. */
const INSTANT = '2026-08-30T10:19:25.947Z';

it('POSITIVE — a JS `Date` (the Postgres/MySQL shape) is reported as canonical ISO-Z text', async () => {
const engine = inventoryEngine({
files: [strandedRow('os13996_pg', { created_at: new Date(INSTANT) })],
});

const report = await inventoryStrandedFileOrphans(engine);

expect(report.stranded).toBe(1);
expect(report.samples).toHaveLength(1);
const sample = report.samples[0];
// The whole defect, in one assertion: this was `undefined` for EVERY row
// on both live dialects, for a field the walk explicitly projects.
expect(
sample.createdAt,
'the driver handed `created_at` out as a Date and the sample dropped it',
).toBe(INSTANT);
expect(typeof sample.createdAt).toBe('string');
expect(sample.createdAt).toMatch(ISO_Z);
});

it('POSITIVE — it is the `toISOString()` spelling, not `String(Date)`: the milliseconds survive', async () => {
const value = new Date(INSTANT);
expect(value.getMilliseconds(), 'the fixture is vacuous without sub-second digits').toBe(947);

const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_ms', { created_at: value })] }),
);
const spelled = report.samples[0].createdAt as string;

// `String(Date)` renders whole seconds in the PROCESS zone (§A2 of the
// driver pin). Naming the instant exactly is what separates the two.
expect(Date.parse(spelled), 'the reported stamp names the row instant').toBe(value.getTime());
expect(spelled).not.toBe(String(value));
expect(Date.parse(String(value))).toBe(value.getTime() - value.getMilliseconds());
});

it('CONTROL (⛔ not ablation evidence — green both directions) — an ISO string is passed through byte-for-byte', async () => {
// The SQLite shape, which already worked. Asserted as the WHOLE sample so
// a change to any neighbouring field would show up here too.
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_sqlite')] }),
);

expect(report.samples).toEqual([
{
fileId: 'os13996_sqlite',
key: 'attachments/os13996_sqlite.bin',
name: 'os13996_sqlite.bin',
size: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
},
]);
});

it('CONTROL — passthrough is TOTAL over strings: a non-canonical stamp is not re-parsed or re-spelled', async () => {
// Today any string reaches the report unchanged, including a naive-UTC
// spelling. The repair adds an accepted shape; it must not quietly start
// validating or normalising the shape that already round-tripped.
for (const text of ['2026-01-01 00:00:00', 'not-a-timestamp', '']) {
const report = await inventoryStrandedFileOrphans(
inventoryEngine({ files: [strandedRow('os13996_text', { created_at: text })] }),
);
expect(report.samples[0].createdAt, `passthrough of ${JSON.stringify(text)}`).toBe(text);
}
});

it('REVERSE CONTROL — an absent, null or unusable stamp stays `undefined`, never a spelled-out one', async () => {
const absent = strandedRow('os13996_absent');
delete (absent as Record<string, unknown>).created_at;

const cases: Array<[string, Record<string, unknown>]> = [
['absent', absent],
['null', strandedRow('os13996_null', { created_at: null })],
// `instanceof Date` is TRUE for this one, and `toISOString()` THROWS on
// it — the case that would take the whole inventory down.
['Invalid Date', strandedRow('os13996_nat', { created_at: new Date('not a date') })],
// Epoch millis: a shape no dialect produces here, kept `undefined`
// rather than stringified into a timestamp position.
['epoch millis', strandedRow('os13996_num', { created_at: Date.parse(INSTANT) })],
];

for (const [label, row] of cases) {
const report = await inventoryStrandedFileOrphans(inventoryEngine({ files: [row] }));

expect(report.stranded, `${label}: the row is still inventoried`).toBe(1);
const sample = report.samples[0];
expect(sample.createdAt, `${label}: an absent stamp must stay absent`).toBeUndefined();
// Spelled out explicitly: these two strings are what a bare `String(…)`
// terminal arm would put where an operator reads a timestamp.
expect(sample.createdAt).not.toBe('Invalid Date');
expect(sample.createdAt).not.toBe('undefined');
// …and dropping the stamp must not drop the row's identity with it.
expect(sample.fileId, `${label}: fileId`).toBe(row.id);
}
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -168,6 +168,57 @@ function usableSize(value: unknown): number | undefined {
return n;
}

/**
* `created_at` as canonical ISO-8601-Z text, whichever shape the driver handed
* it out AS — and `undefined` when the row carries no usable stamp.
*
* ## Why a `typeof v === 'string'` test alone is wrong here
*
* `created_at` is a BUILTIN audit column: it is not in `datetimeFields`, so no
* declared-field coercion reaches it, and `SqlDriver#formatOutput` repairs the
* audit columns only inside its `if (this.isSqlite)` arm. So the read door the
* walk below goes through hands this value back as canonical ISO-Z TEXT on
* SQLite and as a JS `Date` on Postgres and MySQL — the production default
* drivers. Pinned per dialect, at that door, in driver-sql's
* `sql-driver-13567-audit-stamp-materialisation.test.ts` (§B1).
*
* A bare string test therefore answers FALSE for EVERY row on the live
* dialects: a field the walk explicitly projects (`fields: [… 'created_at']`)
* was asked for from the driver and then silently discarded, so every sample
* in the operator's report carried `createdAt: undefined` there while looking
* correct on the SQLite the tests run. The sibling guards on `key` and `name`
* are NOT this — those are text columns on every dialect. Only the timestamp
* straddles the divergence, which is why reading the code did not show it.
*
* ## Why the consumer owes the canonical spelling
*
* Normalising at the driver's read door instead would reverse the deliberate
* `withPostgresCalendarDayAsText` decision — that a `timestamptz` IS an
* instant and a `Date` is the right materialisation for it. So the repair is
* the one `@objectstack/metadata-protocol` already carries for `occurred_at`:
* accept both shapes where the value is consumed.
*
* ⛔ NOT `row.created_at ?? undefined`. That reads as fixed and is worse: it
* puts a raw `Date` into a field declared `string | undefined`, trading a
* dropped field for a wrong type.
*
* ⛔ And the terminal arm is `undefined`, not `String(value)`. This field is
* optional where `occurredAt` is not, and `String()` over a null or an Invalid
* Date spells the literal `"undefined"` / `"Invalid Date"` into an operator's
* report in the position a timestamp is read from — a stamp that is absent
* must stay absent.
*/
function usableCreatedAt(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) {
// An Invalid Date IS `instanceof Date`, and `toISOString()` THROWS on it
// (RangeError) rather than returning something odd. One unparseable stamp
// must not take down a read-only inventory of the whole `sys_file` table.
return Number.isNaN(value.getTime()) ? undefined : value.toISOString();
}
return undefined;
}

/**
* Count the `sys_file` rows that the forward-only fixes (#10171, #10240) would
* have tombstoned had they existed when the rows were orphaned, and that the
Expand DownExpand Up@@ -264,7 +315,7 @@ export async function inventoryStrandedFileOrphans(
key: typeof row.key === 'string' ? row.key : undefined,
name: typeof row.name === 'string' ? row.name : undefined,
size,
createdAt: typeof row.created_at === 'string' ? row.created_at : undefined,
createdAt: usableCreatedAt(row.created_at),
});
}
}
Expand Down
Loading