Skip to content

Commit ddea371

Browse files
Serve canonical ISO-8601 for the import-job DTO's four timestamps on Postgres/MySQL (#14076)
* fix(rest): serve canonical ISO-8601 for the import-job DTO's four timestamps `importJobToProgress` rendered `created_at`, `started_at`, `completed_at` and `reverted_at` through `String(value)`. On Postgres and MySQL those columns are materialised as JS `Date`s, so `String` ran `Date.prototype.toString`: the REST contract served `"Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"` where it promises `"2026-08-30T10:19:25.947Z"` — milliseconds dropped, the server's timezone baked in, no `Z`, not `Date.parse`-safe. Nothing upstream repaired it: `formatOutput`'s `AUDIT_TIMESTAMP_COLUMNS` pass and its `normalizeSqliteDatetimeOutput` pass over `datetimeFields` both sit inside the `if (this.isSqlite)` arm, so a declared `Field.datetime` is not protected on Postgres/MySQL. SQLite returns canonical ISO text, where `String()` was an identity — which is what kept every test green. The four sites now share the three-branch normaliser already landed in `@objectstack/metadata-protocol` (string passthrough -> `instanceof Date` -> `toISOString()` -> last-resort `String(v ?? '')`). Presence semantics are untouched. The new pin drives real `Date`s through the real routes under a forced non-UTC process zone, with a non-vacuity control and an idempotence control for the already-canonical shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(rest): name both landed canonicalisers beside the import-job DTO repair #13997's `canonicalIsoInstant` landed in `@objectstack/metadata-protocol` while this branch was open. Cross-reference it beside `auditMetaItem`'s `occurredAt` form and record why the nullish arm differs here: these four sites are the DTO's last step, and the required `createdAt` field's absent-value spelling (`''`) is folded in rather than left to a caller's `?? <default>` chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * chore(docs): re-anchor the system-context census after the rest-server insertion `check:check-system-context-census` went red with 16 problems: the `canonicalIsoStamp` helper adds a net +56 lines near the top of `rest-server.ts`, so every `isSystem` anchor the census page cites in that file rotted by exactly that offset. Repaired with the gate's own documented remedy for pure line rot (`check-system-context-census.mjs --fix`): 10 anchors re-pointed, uniformly +56, no ledger row added or deleted and no prose touched. Measured as caused here rather than inherited — the census is exit 0 against the pre-fix `rest-server.ts` and exit 1 with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e343de commit ddea371

4 files changed

Lines changed: 366 additions & 8 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): serve canonical ISO-8601 for the import-job DTO's four timestamps on Postgres/MySQL (#13994)
6+
7+
`importJobToProgress` — the mapper behind `GET /data/import/jobs/:jobId`,
8+
`/results` and the history list — rendered `created_at`, `started_at`,
9+
`completed_at` and `reverted_at` through `String(value)`. On Postgres and
10+
MySQL, the production default driver materialises those columns as JS `Date`s,
11+
so `String` ran `Date.prototype.toString` and the REST contract served
12+
13+
```
14+
Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time) <- what the API served
15+
2026-08-30T10:19:25.947Z <- what it promises
16+
```
17+
18+
Milliseconds were dropped, the **server's** timezone was baked into the value,
19+
there was no `Z`, and the result is not `Date.parse`-safe for a client doing
20+
strict ISO parsing. `ImportJobProgressSchema` / `ImportJobSummarySchema`
21+
declare all four as `z.string()` documented "(ISO 8601)", and the client SDK
22+
and objectui's `ImportJobProgressInfo` both restate that as `string` — the
23+
declaration was right, the emitted value was wrong.
24+
25+
Nothing upstream repaired it: `formatOutput`'s two timestamp repairs — the
26+
`AUDIT_TIMESTAMP_COLUMNS` pass and the `normalizeSqliteDatetimeOutput` pass
27+
over `datetimeFields` — both sit inside its `if (this.isSqlite)` arm, so a
28+
declared `Field.datetime` is **not** protected on Postgres/MySQL. SQLite
29+
returns canonical ISO text, where `String()` was an identity — which is why
30+
every SQLite-backed test stayed green for the whole life of the defect.
31+
32+
The four sites now go through the same three-branch normaliser this repo
33+
already landed in `@objectstack/metadata-protocol` (string passthrough →
34+
`instanceof Date``toISOString()` → last-resort `String(v ?? '')`): one
35+
spelling repo-wide, no tolerant `??` fallback, and no change to the presence
36+
semantics — a job that has not started still omits `startedAt` entirely.
37+
38+
Values that were already canonical (every SQLite deployment) are returned
39+
byte-identical, so this changes nothing for them; on Postgres and MySQL a
40+
client that parsed the old string leniently now receives the same instant
41+
spelled correctly, with the milliseconds it previously lost.

content/docs/permissions/system-context.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ not on any flag.
6464
## How the flag is set
6565

6666
`isSystem` is **server-constructed and never client-supplied**. Inbound HTTP
67-
cannot set it (`packages/rest/src/rest-server.ts:1389`, `:1418`), and neither
67+
cannot set it (`packages/rest/src/rest-server.ts:1445`, `:1474`), and neither
6868
can an action body (`packages/runtime/src/domains/actions.ts:404`). It is
6969
written by internal callers only, as an option on the engine call:
7070

@@ -103,7 +103,7 @@ that silently does not happen.
103103
| 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` |
104104
| 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` |
105105
| 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` |
106-
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1421` |
106+
| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1477` |
107107

108108
### 2. Write pipeline and data integrity
109109

@@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4573`, `:5936`, `:6184`, `:6615`, `:6808` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4629`, `:5992`, `:6240`, `:6671`, `:6864` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
@@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs.
199199
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) |
200200
| "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280``281` |
201201
| "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |
202-
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1389`, `:1418`; `domains/actions.ts:404` |
202+
| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1445`, `:1474`; `domains/actions.ts:404` |
203203

204204
---
205205

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13994] The import-job DTO serves CANONICAL ISO-8601 for its four timestamp
5+
* fields, on every dialect and under every process timezone.
6+
*
7+
* ## The defect
8+
*
9+
* `importJobToProgress` rendered all four stamps through `String(v)`. On
10+
* Postgres and MySQL — the production default driver — those columns arrive as
11+
* JS `Date`s, so `String` ran `Date.prototype.toString` and the REST contract
12+
* served
13+
*
14+
* "Sun Aug 30 2026 18:19:25 GMT+0800 (China Standard Time)"
15+
*
16+
* where `ImportJobProgressSchema` promises `"2026-08-30T10:19:25.947Z"`:
17+
* milliseconds dropped, the SERVER's timezone baked in, no `Z`, and not
18+
* `Date.parse`-safe for a client doing strict ISO parsing.
19+
*
20+
* Why all four, and why nothing upstream repaired them: `formatOutput`'s two
21+
* timestamp repairs — the `AUDIT_TIMESTAMP_COLUMNS` pass (`created_at`) and the
22+
* `normalizeSqliteDatetimeOutput` pass over `datetimeFields`
23+
* (`started_at` / `completed_at` / `reverted_at`, all declared `Field.datetime`
24+
* on `sys_import_job`) — both sit INSIDE `formatOutput`'s `if (this.isSqlite)`
25+
* arm. ⚠️ A declared `Field.datetime` is NOT protected on Postgres/MySQL.
26+
*
27+
* ## Why the obvious pin would have proved nothing
28+
*
29+
* SQLite stores and returns canonical ISO text, so `String()` was an IDENTITY
30+
* there and every SQLite-backed test — including this package's real-engine
31+
* `import-job-integration.test.ts` — stayed green through the whole life of the
32+
* defect. A fixture of ISO strings cannot fail. **So these cases drive real
33+
* `Date`s through the real routes**, which is the shape only a non-SQLite
34+
* driver produces, and they do it under a forced non-UTC process zone.
35+
*
36+
* ## What is pinned — the property, not the spelling
37+
*
38+
* Not "the mapper calls `toISOString()`". The invariants are:
39+
*
40+
* 1. **A `Date` from the read door is served as canonical ISO-Z**, on all
41+
* four fields, through both mappers (progress and summary), with the four
42+
* stamps DISTINCT so no field can pass by echoing another's value.
43+
* 2. **The answer does not depend on `process.env.TZ`** — swept over three
44+
* zones, with a non-vacuity control proving those zones really do move the
45+
* broken spelling (three green rows under three identical spellings would
46+
* prove nothing about timezone independence).
47+
* 3. **An already-canonical string is a fixed point** (the SQLite shape is
48+
* returned byte-identical). This is what shows the pin DISCRIMINATES
49+
* rather than being globally sensitive to any change at the seam.
50+
* 4. **The response satisfies the declared contract**, asserted by a full
51+
* `safeParse` against the spec's own `ImportJobProgressSchema` /
52+
* `ImportJobSummarySchema` — the judgement here is about a VALUE, so a
53+
* green parse is the assertion, not merely the absence of unknown keys.
54+
* This limb is also what refuses the tempting "just delete the `String()`
55+
* and let `JSON.stringify` do it" route: that emits the right text but
56+
* widens the declared `z.string()` to `string | Date`.
57+
*
58+
* ## What is deliberately NOT claimed here
59+
*
60+
* That `driver-sql` hands this seam a `Date` on Postgres. That is a fact about
61+
* `driver-sql`, measured beside the fix (`formatOutput`'s `isSqlite` bracketing)
62+
* and pinned in that package; `@objectstack/rest` must not grow a Postgres
63+
* dependency to restate it. What these tests own is the mapper's behaviour
64+
* GIVEN each input shape a driver can produce.
65+
*/
66+
67+
import { describe, it, expect, afterEach } from 'vitest';
68+
// The contract itself, not a local restatement of it: the same schemas
69+
// `ImportJobApiContracts` names as the `output` of these very routes.
70+
import { ImportJobProgressSchema, ImportJobSummarySchema } from '@objectstack/spec/api';
71+
import { RestServer } from './rest-server';
72+
73+
/** Canonical ISO-8601 UTC with milliseconds — what the contract promises. */
74+
const CANONICAL_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
75+
76+
/**
77+
* Four DISTINCT instants, each with a distinct NON-ZERO millisecond component.
78+
* Distinct so no field can pass by echoing another's value; non-zero
79+
* milliseconds so the millisecond-dropping spelling cannot pass by accident.
80+
*/
81+
const CREATED = '2026-08-30T10:19:25.947Z';
82+
const STARTED = '2026-08-30T10:20:31.001Z';
83+
const COMPLETED = '2026-08-30T10:21:44.512Z';
84+
const REVERTED = '2026-08-30T10:22:59.083Z';
85+
86+
const ALL_FOUR = { createdAt: CREATED, startedAt: STARTED, completedAt: COMPLETED, revertedAt: REVERTED };
87+
88+
/** The card's zone, a zone on the other side of UTC, and UTC itself. */
89+
const ZONES = ['Asia/Shanghai', 'America/New_York', 'UTC'] as const;
90+
91+
/**
92+
* One `sys_import_job` row as a driver materialises it. `stamp` decides the
93+
* shape of the four timestamp columns: `Date` (Postgres / MySQL / MongoDB) or
94+
* canonical ISO text (SQLite and friends).
95+
*/
96+
function makeRow(stamp: (iso: string) => unknown) {
97+
return {
98+
id: 'imp_13994',
99+
object_name: 'task',
100+
status: 'succeeded',
101+
dry_run: false,
102+
write_mode: 'insert',
103+
total_rows: 3,
104+
processed_rows: 3,
105+
created_count: 2,
106+
updated_count: 0,
107+
skipped_count: 0,
108+
error_count: 1,
109+
created_at: stamp(CREATED),
110+
started_at: stamp(STARTED),
111+
completed_at: stamp(COMPLETED),
112+
reverted_at: stamp(REVERTED),
113+
};
114+
}
115+
116+
function createMockServer() {
117+
const noop = () => {};
118+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
119+
}
120+
121+
function makeRes() {
122+
const res: any = {
123+
write: () => true, end: () => {},
124+
header: () => res,
125+
status: (code: number) => { res._status = code; return res; },
126+
json: (body: any) => { res._json = body; return res; },
127+
};
128+
return res;
129+
}
130+
131+
/**
132+
* The REAL routes, over a protocol whose read door returns exactly `row`.
133+
*
134+
* A stub read door rather than a real engine ON PURPOSE: the shape under test
135+
* is the one a SQLite-backed engine cannot produce, and it is precisely the
136+
* unreachability of that shape from SQLite that hid this defect.
137+
*/
138+
function boot(row: unknown) {
139+
const protocol = { findData: async () => ({ records: [row] }) };
140+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
141+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
142+
rest.registerRoutes();
143+
const routes = rest.getRoutes();
144+
const find = (method: string, path: string) => routes.find((r: any) => r.method === method && r.path === path);
145+
return {
146+
progress: find('GET', '/api/v1/data/import/jobs/:jobId'),
147+
results: find('GET', '/api/v1/data/import/jobs/:jobId/results'),
148+
list: find('GET', '/api/v1/data/import/jobs'),
149+
};
150+
}
151+
152+
async function call(route: any, req: any = {}) {
153+
const res = makeRes();
154+
await route.handler({ params: { jobId: 'imp_13994' }, query: {}, ...req } as any, res);
155+
return res._json;
156+
}
157+
158+
const ORIGINAL_TZ = process.env.TZ;
159+
afterEach(() => {
160+
if (ORIGINAL_TZ === undefined) delete process.env.TZ;
161+
else process.env.TZ = ORIGINAL_TZ;
162+
});
163+
164+
describe('[#13994] the import-job DTO serves canonical ISO-8601 for a `Date` from the read door', () => {
165+
it('renders all four stamps canonically under a forced non-UTC process zone', async () => {
166+
process.env.TZ = 'Asia/Shanghai';
167+
168+
// NON-VACUITY CONTROL. The defect's spelling, evaluated right here under
169+
// the same zone: if `String(Date)` already produced canonical ISO, the
170+
// assertions below would be green against the broken code too.
171+
const broken = String(new Date(CREATED));
172+
expect(broken, 'the broken spelling did not move — this pin would be vacuous').not.toBe(CREATED);
173+
expect(broken).not.toMatch(CANONICAL_ISO);
174+
175+
const body = await call(boot(makeRow((iso) => new Date(iso))).progress);
176+
177+
// All four, each against ITS OWN instant — distinct values, so a mapper
178+
// that echoed one stamp into all four fields fails here.
179+
expect(body).toMatchObject(ALL_FOUR);
180+
for (const [field, value] of Object.entries(ALL_FOUR)) {
181+
expect(body[field], `${field} is not canonical ISO-Z`).toMatch(CANONICAL_ISO);
182+
// Strict-ISO round-trip: what a client doing `Date.parse` receives.
183+
expect(new Date(body[field]).toISOString()).toBe(value);
184+
}
185+
186+
// Limb 4: the declared contract, parsed by the spec's own schema. A bare
187+
// `Date` here (the "just delete the String()" route) fails this.
188+
const parsed = ImportJobProgressSchema.safeParse(body);
189+
expect(parsed.success, JSON.stringify((parsed as any).error?.issues)).toBe(true);
190+
});
191+
192+
it('gives the same answer under every process timezone, and the zones really do move the broken spelling', async () => {
193+
const served = new Set<string>();
194+
const brokenSpellings = new Set<string>();
195+
196+
for (const zone of ZONES) {
197+
process.env.TZ = zone;
198+
brokenSpellings.add(String(new Date(CREATED)));
199+
const body = await call(boot(makeRow((iso) => new Date(iso))).progress);
200+
served.add(JSON.stringify([body.createdAt, body.startedAt, body.completedAt, body.revertedAt]));
201+
}
202+
203+
// The control: three zones, three DIFFERENT broken spellings. Without
204+
// this, three green rows would say nothing about timezone independence.
205+
expect(
206+
brokenSpellings.size,
207+
'the process zone did not move `String(Date)` — the sweep is vacuous',
208+
).toBe(ZONES.length);
209+
210+
// The property: one answer, whatever the server's zone.
211+
expect(served).toEqual(new Set([JSON.stringify([CREATED, STARTED, COMPLETED, REVERTED])]));
212+
});
213+
214+
it('serves the summary (list) DTO canonically too', async () => {
215+
process.env.TZ = 'Asia/Shanghai';
216+
const body = await call(boot(makeRow((iso) => new Date(iso))).list);
217+
const [job] = body.jobs;
218+
219+
// `importJobToSummary` re-reads `importJobToProgress`'s output, so this
220+
// is the second mapper's face on the same repair.
221+
expect(job).toMatchObject({ createdAt: CREATED, completedAt: COMPLETED, revertedAt: REVERTED });
222+
for (const field of ['createdAt', 'completedAt', 'revertedAt'] as const) {
223+
expect(job[field], `${field} is not canonical ISO-Z`).toMatch(CANONICAL_ISO);
224+
}
225+
const parsed = ImportJobSummarySchema.safeParse(job);
226+
expect(parsed.success, JSON.stringify((parsed as any).error?.issues)).toBe(true);
227+
});
228+
229+
it('serves the results DTO canonically too', async () => {
230+
process.env.TZ = 'Asia/Shanghai';
231+
const body = await call(boot(makeRow((iso) => new Date(iso))).results);
232+
expect(body).toMatchObject(ALL_FOUR);
233+
});
234+
});
235+
236+
describe('[#13994] an already-canonical string is a fixed point — the pin discriminates', () => {
237+
it('returns the SQLite shape byte-identical, under a non-UTC zone', async () => {
238+
process.env.TZ = 'Asia/Shanghai';
239+
const body = await call(boot(makeRow((iso) => iso)).progress);
240+
241+
// Idempotence: the dialect that was already correct must not move. A
242+
// repair that re-derived every value (`new Date(v).toISOString()`) would
243+
// pass the `Date` cases above and still be a change in behaviour here.
244+
expect(body).toMatchObject(ALL_FOUR);
245+
expect(ImportJobProgressSchema.safeParse(body).success).toBe(true);
246+
});
247+
248+
it('leaves a missing optional stamp absent, and an absent `created_at` an empty string', async () => {
249+
process.env.TZ = 'Asia/Shanghai';
250+
// The presence-guards are semantics this repair does NOT touch: a job
251+
// that has not started yet omits the three optional stamps entirely.
252+
const row: any = makeRow((iso) => new Date(iso));
253+
delete row.started_at; delete row.completed_at; delete row.reverted_at; delete row.created_at;
254+
255+
const body = await call(boot(row).progress);
256+
expect('startedAt' in body).toBe(false);
257+
expect('completedAt' in body).toBe(false);
258+
expect('revertedAt' in body).toBe(false);
259+
expect(body.createdAt).toBe('');
260+
});
261+
});

0 commit comments

Comments
 (0)