Skip to content

Commit be55fd2

Browse files
fix(objectql): a seed row keeps its authored created_at on insert, as the replay already does (#21661)
Fixes #21646 Clause-②: no - An authored seed value that was silently dropped on insert is now kept, as the replay already keeps it. - No accepted input, key, export or error code is added or removed. - No contract surface is touched. ## What changed `sys_stamp_audit_insert` (`packages/objectql/src/plugin.ts`, `applyToRecord`) now keeps an authored `created_at` (`?? now`) on a seed write (`ExecutionContext.seedReplay`), as it already did under `preserveAudit`: ```ts record.created_at = preserveAudit || seedReplay ? (record.created_at ?? now) : now; ``` That one ternary is the only change to the stamp. `updated_at`, `created_by`, `updated_by`, `tenant_id` and the whole update stamp behave exactly as before. `preserveAudit` is not touched. `SEED_WRITE_EXECUTION_CONTEXT` is read, not edited, so the seed context does not gain `preserveAudit`. A bare `isSystem` context, REST and every other caller still stamp now. ## Where the hook reads `seedReplay` (H1, measured: one detail of the suggested route changed) - **The hook session has no `seedReplay`.** The stamp reads its flags from `hookCtx.session`. `buildSession` (`engine.ts`) builds that object one field at a time: `userId`, `organizationId`, `positions`, `accessToken`, `isSystem`, `actor`, `skipTriggers`, `skipAutomations` and `preserveAudit`. It never copies `seedReplay`, so a branch on `session.seedReplay` would read `undefined` on every seed write. - **The flag is read from the options bag.** The `beforeInsert` envelope's `input.options` is the caller's own options bag. The HookContext `input` PHASE contract in `packages/spec/src/data/hook.zod.ts` says so: a `before*` handler reads the caller's bag. `engine.insert` is the only `beforeInsert` dispatch site, and it sets `opCtx.context = options?.context`. So `hookCtx.input.options.context.seedReplay` is the seed context exactly as the seeder built it. The new helper `isSeedReplay` reads it there. - **Not taken: copying `seedReplay` into `buildSession`.** That would create a key the engine produces but no contract declares. Fixing that would need a new key on `HookContextSchema.session` in `packages/spec`, which is a contract surface this card's Clause-② says it does not touch. This builtin would be the key's only reader. - **Trust is the same on both routes.** `seedReplay` is a `NonEntryExecutionContextField` (`packages/core/src/security/assemble-execution-context.ts`), so no transport entry point builds it from a request. The session is also built from that same context object. - **`skipTriggers` does not skip the audit hooks.** `triggerHooks` reads only `session.skipAutomations`, and only to skip hooks bound from metadata. `skipTriggers` gates flow dispatch, never the code-registered audit hooks, and the seed context carries no `skipAutomations`. Pin 2 shows this: an unauthored seed row is stamped by the hook at boot. ## H2: `created_by` (measured, no change) The seed context carries no `userId`. The stamp assigns `created_by` and `updated_by` only inside `if (session?.userId)`, on both events, so it writes neither on a seed write. Seed writes are `isSystem`, so the readonly strip does not run on either path. The result: - an authored `created_by` is kept on the insert and on the replay update; - an unauthored `created_by` stays absent. Both paths already behaved the same before this change. A companion test records the measurement, labelled as green on both sides of the change. ## H3: three readers, one context (measured) | reader | call | covered by | | --- | --- | --- | | `SeedLoaderService` | `engine.insertMany` / `insert` / `update` with `{ context: SEED_WRITE_EXECUTION_CONTEXT }` | pins 1, 2 and 4, through `load()` | | `AppPlugin` replaying a stack's `data[]` | main path: `new SeedLoaderService(ql, …).load()`; both fallbacks: `ql.insert(object, record, SEED_WRITE_OPTIONS)` per row | pin 1 (loader) and pin 1's single-row case | | `@objectstack/verify` `seed()` | `ql.insert(object, rows, { context: SEED_CONTEXT })` with an array, where `SEED_CONTEXT = SEED_WRITE_EXECUTION_CONTEXT` | pin 1's array case | All three reach the stamp with `seedReplay` set. There is no producer to fix. The other `new SeedLoaderService(…)` sites (package install in `protocol.ts`, `runtime/src/domains/packages.ts`, and the other `app-plugin.ts` sites) are the same loader. ## H4: `cel` values (measured) `SeedLoaderService` evaluates every Expression envelope (`resolveSeedRecord`) before it decides insert or update. The insert and the update therefore both receive the evaluated instant, which is a `Date` for ``cel`daysAgo(5)` ``. Pin 1 reads it back as `2026-09-28T00:00:00.000Z` on both boots, the value the card measured on its replay. ## H5: the warning (measured) `preserveAuditIgnoredOnInsertWarning` is emitted only from the non-`isSystem` branch of `engine.insert`'s create-side strip, and only when `preserveAudit` was requested. A seed write is `isSystem` and has no `preserveAudit`, so the warning cannot fire for it, before or after this change. Pin 4 checks both halves. Two seed boots produce no line with the warning's lead clause. A non-system `{ userId, preserveAudit: true }` create produces exactly `preserveAuditIgnoredOnInsertWarning('seed_case', ['run_at'])`. The expected line comes from the producer function, not from a copy of its text. ## Tests New: `packages/objectql/src/plugin-audit-seed-created-at.test.ts`. It boots a real `ObjectKernel` with `ObjectQLPlugin`, so the audit hooks are bound the way a booted app binds them. It runs the real `SeedLoaderService.load()` twice over one store-backed stub driver: a fresh boot, then a replay. `Date` is faked to two instants on the same UTC day. - **Pin 1.** An authored `created_at` is kept on the fresh boot (INSERT) and on the replay (UPDATE). Row A is ``cel`daysAgo(5)` `` and reads `2026-09-28T00:00:00.000Z`; row B is `2026-09-01T12:00:00.000Z`. The replay is a real update (`totalUpdated: 3`) and moves `updated_at` to the second boot. A second case covers the call shapes of the other two readers. - **Pin 2.** A seed row with no `created_at` is stamped at boot, and the replay leaves that stamp alone. - **Pin 3.** A non-seed system insert (`{ isSystem }` and `{ isSystem, skipTriggers }`) and a REST insert (the protocol's `createData`, the door `POST /api/v1/data/OBJECT` uses) are all stamped now. - **Pin 4.** The `preserveAudit` insert warning is unchanged, as described under H5. - **Companion.** The `created_by` measurement from H2. Pre-fix reading, the same file against unfixed `plugin.ts`: `Tests 2 failed | 4 passed (6)`. Both pin 1 cases fail with `expected '2026-10-03T12:00:00.000Z' to be '2026-09-28T00:00:00.000Z'`, which is the card's table: the boot instant replaced the authored value. Readings at `e5a2555da6` (the head after merging `origin/main` `6ec54f00ba`), unless a different commit is named: - **The pin file and its four nearest neighbours**, run with `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2`: `plugin-audit-seed-created-at`, `plugin-audit-created-at-create-side`, `plugin-audit-created-by-create-side`, `engine-seed-required-deferral` and `seed-loader-org-stamp`. Result: `Test Files 5 passed (5)`, `Tests 24 passed (24)`. - **The whole local project** (`vitest run --project local --maxWorkers=2`): `Test Files 370 passed (370)`, `Tests 7439 passed (7439)`. - **The repo project** (`--project repo`, one file): `Tests 5 passed (5)`, read at `2a8264570c`. - **`pnpm --filter @objectstack/objectql typecheck`**: exit 0. This covers `tsc` over src and scripts, plus `check:test-typecheck` over the test layer, which reported `40 file(s) / 234 error(s) / 65 pinned signature(s) held`. The ledger is unchanged. `--listFilesOnly` shows that program includes the new test file. ## Acceptance notes - **Under the seed context, a malformed authored `created_at` is now stored on the first insert.** Before this change it was stored only on the replay update. Measured through `SeedLoaderService.load()` over the kernel's engine: a literal `'yesterday'` on an author-declared `readonly` datetime, and on `created_at`, is stored verbatim with no error. The same literal on a non-readonly datetime is refused (`must be a valid datetime (ISO-8601)`) and reported as a seed error. A raw ``cel`…` `` envelope, which only a writer that skips `resolveSeedRecord` can send (`AppPlugin`'s two fallbacks, `verify.seed()`), is stored the same way. On the update path the envelope was already stored for `created_at` before this change. The cause is outside this card: a system-context write does not check the value shape of `readonly` fields. That is reported to the seat as a separate finding, not fixed here. - Two descriptions of `preserveAudit` still say "symmetric with how `created_at` / `created_by` (already) behave on insert": the `ExecutionContext.preserveAudit` TSDoc and the `HookContext.session.preserveAudit` TSDoc. That has not been true since `created_at` and `created_by` stopped being client-preferred on an ordinary insert. This is older drift, outside this card, and not fixed here. - The `seedReplay` TSDoc in `execution-context.zod.ts` lists what the flag exempts, which is only the `state_machine` rule. It does not mention that the audit stamp now keeps an authored `created_at` under it. Its statements are still true, so this change does not make them false. The file is outside this card's surface. - `content/docs/data-modeling/seed-data.mdx` already shows ``created_at: cel`now()` `` in a seed record. That value is now kept on insert as well as on replay. No doc text changes. ## Reverse verification The fix was committed first. The reverse leg then reverted only the stamp's `seedReplay` arm. It went through `scripts/ablation-replace.mjs`, whose anchor must hit, and a shell `trap` also ran `git checkout HEAD --` on the absolute path. Predicted direction: pin 1 red, everything else green. Observed at `e5a2555da6`, and identically at `2a8264570c`: - **The mutation landed.** The anchor count went 1 → 0 and the reverted form 0 → 1. The blob went `e34d4989074d` → `4645b78e8872`. - **Only pin 1 went red.** Result: `Tests 2 failed | 4 passed (6)`. Both pin 1 cases failed with `expected '2026-10-03T12:00:00.000Z' to be '2026-09-28T00:00:00.000Z'` and `… to be '2026-09-01T12:00:00.000Z'`. Pins 2, 3 and 4 and the companion stayed green. - **The file was restored.** The blob after restore equals the HEAD blob (`e34d4989074d`), `git diff HEAD` is 0 bytes, and `git status --porcelain` is empty. No `dist/` build is in this loop. The pin file imports `./plugin.js` by relative path, so vitest reads the mutation from `src/`. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, run with no paths at `e5a2555da6`, derives 64 commands for this change set. That is the dispatch list's 50 plus 14 more: - `check-adr-0087-registration` (twice); - `check-empty-changeset` (twice); - `release-rehearsal-clone --self-test`; - `release-pending-publish --self-test`; - `check:engine-double-contract`; - `check:objectql-double-limit`; - `check:objectui-changeset`; - `check:pm-changeset-deadline-census`; - `check:query-options-erasure`; - `check:type-check-coverage`; - `check:type-check-debt`; - `check:where-matcher`. All 64 ran at `e5a2555da6`, with each exit code captured before any pipe: 64 × exit 0. The `--ran` reconciliation reports `64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero — all 64 recorded an exit code and none of them is 3)`. - `check:objectql-double-limit` failed on the first run because the new stub driver's `find` ignored `limit`. `f135b5c542` fixes that: `find` now applies `ast.limit` after the filter. - NOT MEASURED locally: the families the derivation lists outside its total, which run only in CI. These are shard attestation, test completeness, the Test Core, Dogfood, Temporal Conformance and Build Core jobs, and the workspace type-check lanes. The repository-wide `pnpm lint` also runs only in CI. - `origin/main` gained one more commit after the merge: `eea82af677`, metadata-protocol's view-container save door. None of its files is in this diff. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55e6f14 commit be55fd2

3 files changed

Lines changed: 388 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
A seed row's authored `created_at` is kept when the row is first inserted, the same as when a later boot replays it (#21646).
6+
7+
Clause-②: no
8+
9+
- **Before.** The built-in audit stamp (`sys_stamp_audit_insert`) replaced a seed row's authored `created_at` with the boot instant on insert. A later boot's upsert update then wrote the authored value, so a fresh or reset database showed every seeded record as created at boot until the next restart. This held for a literal instant and for a `cel` value such as ``cel`daysAgo(5)` ``.
10+
- **After.** Under the seed write context (`ExecutionContext.seedReplay`, set by `SEED_WRITE_EXECUTION_CONTEXT`), the insert stamp keeps an authored `created_at` and stamps the boot instant only when the row has none. Both paths now store the authored value. The update stamp is unchanged, so `updated_at` still moves on a replay. All three seed writers pass that context: `SeedLoaderService`, `AppPlugin`'s replay of a stack's `data[]`, and `@objectstack/verify`'s `seed()`.
11+
- **Unchanged.** A REST create, a create from a bare `isSystem` context and every other caller still have `created_at` stamped now. `preserveAudit` is unchanged, and the seed context does not gain it. A non-system create that requests `preserveAudit` gets the same warning as before. `created_by` is not stamped on a seed write, because the seed context has no user. An authored value is kept on insert and on replay, as it was before this change.
12+
- ⛔ No schema, key, export or error code is added or removed.
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #21646 — a seed row's authored `created_at` is KEPT on the INSERT of a fresh
4+
// boot, exactly as the upsert UPDATE of a later boot already keeps it.
5+
//
6+
// ## The defect, as measured on a real app's seed (17.6.0)
7+
//
8+
// seed row authored created_at fresh boot (INSERT) second boot (UPDATE)
9+
// -------- -------------------------------- -------------------- ---------------------
10+
// A cel`daysAgo(5)` boot instant the authored value
11+
// B '2026-09-01T12:00:00.000Z' boot instant the authored value
12+
// control none boot instant unchanged
13+
//
14+
// The audit binder's `beforeInsert` stamp (`sys_stamp_audit_insert`) kept a
15+
// supplied `created_at` only under `preserveAudit`, and the seed write context
16+
// (`SEED_WRITE_EXECUTION_CONTEXT` = `{ isSystem, skipTriggers, seedReplay }`)
17+
// carries no `preserveAudit`. The `beforeUpdate` stamp touches `updated_at`
18+
// only, and a seed write is `isSystem`, so the readonly strip never ran on the
19+
// replay either — the authored value was written there. One value, two paths,
20+
// two outcomes.
21+
//
22+
// ## Ruling (triage, verbatim from "Ruling" to the pins)
23+
//
24+
// > **Ruling: key the insert stamp on `seedReplay`, not on `preserveAudit`.**
25+
// > - `preserveAudit` is "UPDATE-only … never when it is created" … ⛔ So the
26+
// > fix does **not** add `preserveAudit` to the seed context.
27+
// > - So under `seedReplay` the insert stamp keeps an authored `created_at`
28+
// > (`?? now`), as the replay already does.
29+
// > - ⛔ Not under bare `isSystem`: a system clone could carry a source row's
30+
// > `created_at`.
31+
// > - ⛔ No change for REST or any other caller.
32+
//
33+
// The four pins below are the ruling's four, on the real seed boot path:
34+
// `SeedLoaderService.load()` through the kernel's own ObjectQL engine, with
35+
// `ObjectQLPlugin`'s audit hooks bound exactly as a booted app binds them. A
36+
// "boot" is one `load()` over the same store; the second one is the replay.
37+
//
38+
// ## Where the stamp reads `seedReplay`
39+
//
40+
// The hook's `session` (engine `buildSession`) carries `isSystem`, the skip
41+
// flags and `preserveAudit` — NOT `seedReplay`. The `beforeInsert` envelope's
42+
// `input.options` IS the caller's own options bag (HookContext `input` PHASE
43+
// contract, `packages/spec/src/data/hook.zod.ts`), so its `context` is the
44+
// write's ExecutionContext as the seeder built it. That is where the stamp
45+
// reads it, and every seed reader passes the one shared constant there.
46+
47+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
48+
import { ObjectKernel } from '@objectstack/core';
49+
import { cel } from '@objectstack/spec';
50+
import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel';
51+
import { SeedLoaderService, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
52+
import { ObjectQLPlugin } from './plugin.js';
53+
import { ObjectQL } from './engine.js';
54+
import { preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js';
55+
56+
const OBJECT = 'seed_case';
57+
/** Boot instants. Same UTC day, so `daysAgo(5)` names one calendar day on both. */
58+
const BOOT_1 = '2026-10-03T12:00:00.000Z';
59+
const BOOT_2 = '2026-10-03T13:00:00.000Z';
60+
/** `daysAgo(5)` at either boot: UTC midnight of the calendar day five days back. */
61+
const DAYS_AGO_5 = '2026-09-28T00:00:00.000Z';
62+
const AUTHORED_B = '2026-09-01T12:00:00.000Z';
63+
const FORGED = '1999-01-01T00:00:00.000Z';
64+
65+
/** A store-backed stub driver: the stored row IS the verdict. */
66+
function makeStubDriver() {
67+
const stores = new Map<string, Map<string, any>>();
68+
const storeFor = (o: string) => {
69+
let s = stores.get(o);
70+
if (!s) { s = new Map(); stores.set(o, s); }
71+
return s;
72+
};
73+
const checkOp = (value: any, cond: any): boolean => {
74+
if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) {
75+
return value === cond;
76+
}
77+
return Object.entries(cond).every(([op, target]: [string, any]) => {
78+
switch (op) {
79+
case '$eq': return value === target;
80+
case '$ne': return value !== target;
81+
case '$in': return Array.isArray(target) && target.includes(value);
82+
// REFUSE, never silently match — an unsupported operator answered
83+
// `true` would read as a hit for every row.
84+
default: throw new Error(`stub driver: unsupported operator ${op}`);
85+
}
86+
});
87+
};
88+
const matches = (row: any, where: any): boolean => {
89+
if (!where || typeof where !== 'object') return true;
90+
return Object.entries(where).every(([k, v]: [string, any]) => {
91+
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
92+
if (k === '$or') return (v as any[]).some((w) => matches(row, w));
93+
if (k.startsWith('$')) throw new Error(`stub driver: unsupported combinator ${k}`);
94+
return checkOp(row?.[k], v);
95+
});
96+
};
97+
let n = 0;
98+
const driver: any = {
99+
name: 'seed-store', version: '0.0.0', supports: {},
100+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
101+
async syncSchema() {},
102+
async find(object: string, ast: any) {
103+
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
104+
const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
105+
return page.map((r) => ({ ...r }));
106+
},
107+
async findOne(object: string, ast: any) {
108+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r };
109+
return null;
110+
},
111+
async create(object: string, data: Record<string, unknown>) {
112+
n += 1;
113+
const id = (data.id as string) ?? `r_${n}`;
114+
const row = { ...data, id };
115+
storeFor(object).set(id, row);
116+
return { ...row };
117+
},
118+
async update(object: string, id: string, data: Record<string, unknown>) {
119+
const s = storeFor(object);
120+
const row = { ...s.get(id), ...data, id };
121+
s.set(id, row);
122+
return { ...row };
123+
},
124+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
125+
async count(object: string, ast: any) {
126+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).length;
127+
},
128+
};
129+
return { driver, storeFor };
130+
}
131+
132+
function emptyMetadata() {
133+
return {
134+
getObject: async () => undefined,
135+
listObjects: async () => [],
136+
register: async () => {},
137+
get: async () => undefined,
138+
list: async () => [],
139+
unregister: async () => {},
140+
exists: async () => false,
141+
listNames: async () => [],
142+
};
143+
}
144+
145+
const quietLogger = { info() {}, warn() {}, error() {}, debug() {} };
146+
147+
const LOAD_CONFIG = {
148+
dryRun: false,
149+
haltOnError: false,
150+
multiPass: true,
151+
defaultMode: 'upsert',
152+
batchSize: 1000,
153+
transaction: false,
154+
} as any;
155+
156+
/** The card's three rows; `subject` differs per boot so the replay is a real UPDATE. */
157+
const seedFor = (subject: string) => ({
158+
object: OBJECT,
159+
externalId: 'code',
160+
mode: 'upsert',
161+
env: ['prod', 'dev', 'test'],
162+
records: [
163+
{ code: 'A', subject, created_at: cel`daysAgo(5)` },
164+
{ code: 'B', subject, created_at: AUTHORED_B },
165+
{ code: 'C', subject },
166+
],
167+
});
168+
169+
/** An instant, read the same way whether the driver was handed a `Date` or a string. */
170+
const instant = (v: unknown) => new Date(v as any).toISOString();
171+
172+
describe('audit binder: a seed row keeps its authored `created_at` (#21646)', () => {
173+
let kernel: ObjectKernel;
174+
let objectql: ObjectQL;
175+
let storeFor: ReturnType<typeof makeStubDriver>['storeFor'];
176+
let warns: string[];
177+
178+
beforeEach(async () => {
179+
vi.useFakeTimers({ toFake: ['Date'] });
180+
vi.setSystemTime(new Date(BOOT_1));
181+
kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
182+
const stub = makeStubDriver();
183+
storeFor = stub.storeFor;
184+
await kernel.use({
185+
name: 'seed-store-plugin', type: 'driver', version: '1.0.0',
186+
init: async (ctx: any) => { ctx.registerService('driver.seed-store', stub.driver); },
187+
} as any);
188+
await kernel.use(new ObjectQLPlugin());
189+
await kernel.bootstrap();
190+
objectql = kernel.getService<ObjectQL>('objectql');
191+
// `created_at` / `created_by` are NOT declared: the registry injects them
192+
// from `AUDIT_FIELD_DEFS` (`readonly: true`), as on every real object.
193+
objectql.registry.registerObject({
194+
name: OBJECT,
195+
label: 'Seed Case',
196+
datasource: 'seed-store',
197+
fields: {
198+
code: { name: 'code', label: 'Code', type: 'text' },
199+
subject: { name: 'subject', label: 'Subject', type: 'text' },
200+
run_at: { name: 'run_at', label: 'Run At', type: 'datetime', readonly: true },
201+
},
202+
} as any, 'test', 'test');
203+
warns = [];
204+
const engineLogger = (objectql as any).logger;
205+
vi.spyOn(engineLogger, 'warn').mockImplementation((...a: unknown[]) => { warns.push(String(a[0])); });
206+
});
207+
208+
afterEach(async () => {
209+
vi.useRealTimers();
210+
vi.restoreAllMocks();
211+
if (kernel.getState() === 'running') await kernel.shutdown();
212+
});
213+
214+
const loader = () => new SeedLoaderService(objectql as never, emptyMetadata() as never, quietLogger as never);
215+
const boot = async (at: string, subject: string) => {
216+
vi.setSystemTime(new Date(at));
217+
const result = await loader().load({ seeds: [seedFor(subject)] as never, config: LOAD_CONFIG });
218+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
219+
return result;
220+
};
221+
const stored = (code: string) => {
222+
const rows = Array.from(storeFor(OBJECT).values()).filter((r) => r.code === code);
223+
expect(rows.length, `exactly one stored row for code ${code}`).toBe(1);
224+
return rows[0];
225+
};
226+
227+
// ── Pin 1 ────────────────────────────────────────────────────────────────
228+
it('pin 1: an authored `created_at` is kept on the fresh boot (INSERT) and on the replay (UPDATE)', async () => {
229+
const fresh = await boot(BOOT_1, 'v1');
230+
expect(fresh.summary.totalInserted).toBe(3);
231+
232+
// The card's two rows. A `cel` value is evaluated by the loader BEFORE the
233+
// write decision, so the INSERT is handed the instant, never the envelope.
234+
expect(instant(stored('A').created_at)).toBe(DAYS_AGO_5);
235+
expect(instant(stored('B').created_at)).toBe(AUTHORED_B);
236+
237+
const replay = await boot(BOOT_2, 'v2');
238+
// A real UPDATE of every row, not a skipped no-op replay.
239+
expect(replay.summary.totalUpdated).toBe(3);
240+
expect(stored('A').subject).toBe('v2');
241+
242+
expect(instant(stored('A').created_at)).toBe(DAYS_AGO_5);
243+
expect(instant(stored('B').created_at)).toBe(AUTHORED_B);
244+
// The replay is still an UPDATE as far as the binder is concerned: the
245+
// last-modified stamp moves to the second boot.
246+
expect(stored('A').updated_at).toBe(BOOT_2);
247+
expect(stored('B').updated_at).toBe(BOOT_2);
248+
});
249+
250+
// The three seed readers pass one constant, `SEED_WRITE_EXECUTION_CONTEXT`.
251+
// `SeedLoaderService` is pin 1. `AppPlugin`'s fallback replay of a stack's
252+
// `data[]` calls `ql.insert(object, record, { context: <it> })` per row, and
253+
// `@objectstack/verify`'s `seed()` calls `ql.insert(object, rows, { context:
254+
// <it> })` with an array — both shapes, through the same engine.
255+
it('pin 1, the other two readers\' call shapes: a single-row and an array insert under the seed context keep it too', async () => {
256+
await objectql.insert(OBJECT, { code: 'single', created_at: AUTHORED_B }, { context: SEED_WRITE_EXECUTION_CONTEXT });
257+
await objectql.insert(
258+
OBJECT,
259+
[{ code: 'arr0', created_at: AUTHORED_B }, { code: 'arr1' }],
260+
{ context: SEED_WRITE_EXECUTION_CONTEXT },
261+
);
262+
263+
expect(stored('single').created_at).toBe(AUTHORED_B);
264+
expect(stored('arr0').created_at).toBe(AUTHORED_B);
265+
expect(stored('arr1').created_at).toBe(BOOT_1);
266+
});
267+
268+
// ── Pin 2 ────────────────────────────────────────────────────────────────
269+
it('pin 2: a seed row with no `created_at` is stamped at boot, and the replay leaves that stamp alone', async () => {
270+
await boot(BOOT_1, 'v1');
271+
// Stamped by the binder: the hook ran under `skipTriggers`, which
272+
// suppresses flow dispatch, never the code-registered audit hooks.
273+
expect(stored('C').created_at).toBe(BOOT_1);
274+
275+
await boot(BOOT_2, 'v2');
276+
expect(stored('C').created_at).toBe(BOOT_1);
277+
expect(stored('C').updated_at).toBe(BOOT_2);
278+
});
279+
280+
// ── Pin 3 ────────────────────────────────────────────────────────────────
281+
it('pin 3: a non-seed system insert and a REST insert are both stamped now', async () => {
282+
// System contexts WITHOUT `seedReplay`: bare elevation, and the seed
283+
// posture minus its one load-bearing flag.
284+
await objectql.insert(OBJECT, { code: 'sys', created_at: FORGED }, { context: { isSystem: true } });
285+
await objectql.insert(
286+
OBJECT,
287+
{ code: 'sys_quiet', created_at: FORGED },
288+
{ context: { isSystem: true, skipTriggers: true } },
289+
);
290+
// The REST data door: `POST /api/v1/data/OBJECT` reaches the engine through
291+
// the protocol's `createData` with the caller's assembled context.
292+
const protocol = new ObjectStackProtocolImplementation(objectql as never);
293+
await protocol.createData({ object: OBJECT, data: { code: 'rest', created_at: FORGED }, context: { userId: 'user-1' } });
294+
295+
expect(stored('sys').created_at).toBe(BOOT_1);
296+
expect(stored('sys_quiet').created_at).toBe(BOOT_1);
297+
expect(stored('rest').created_at).toBe(BOOT_1);
298+
});
299+
300+
// ── Pin 4 ────────────────────────────────────────────────────────────────
301+
it('pin 4: the `preserveAudit` insert warning is unchanged — it fires for a non-system create, never for a seed insert', async () => {
302+
// The lead clause, derived from the producer rather than spelled here.
303+
const lead = preserveAuditIgnoredOnInsertWarning('', []).split(':')[0];
304+
305+
await boot(BOOT_1, 'v1');
306+
await boot(BOOT_2, 'v2');
307+
expect(warns.filter((w) => w.startsWith(lead))).toEqual([]);
308+
309+
await objectql.insert(
310+
OBJECT,
311+
{ code: 'hist', run_at: FORGED, created_at: FORGED },
312+
{ context: { userId: 'user-1', preserveAudit: true } },
313+
);
314+
expect(warns.filter((w) => w.startsWith(lead))).toEqual([
315+
preserveAuditIgnoredOnInsertWarning(OBJECT, ['run_at']),
316+
]);
317+
// The historical-import channel itself is untouched: the binder keeps the
318+
// authored `created_at` under `preserveAudit`, the strip takes `run_at`.
319+
expect(stored('hist').created_at).toBe(FORGED);
320+
expect(stored('hist').run_at).toBeUndefined();
321+
});
322+
323+
// ── `created_by`, measured (a companion, not a pin) ──────────────────────
324+
// The seed context carries no `userId`, and the binder assigns the audit
325+
// user fields only inside `if (session?.userId)`, on either event. So an
326+
// authored `created_by` is untouched on the INSERT and on the UPDATE, and an
327+
// unauthored one stays absent. This was already true before #21646 and the
328+
// change does not touch it — green on both sides, so it is evidence of the
329+
// measurement, not of the fix.
330+
it('companion: an authored `created_by` is kept on the seed INSERT and the seed UPDATE; none is stamped', async () => {
331+
const [row] = await objectql.insert(
332+
OBJECT,
333+
[{ code: 'by', created_by: 'usr_seed_author' }, { code: 'by_none' }],
334+
{ context: SEED_WRITE_EXECUTION_CONTEXT },
335+
);
336+
expect(stored('by').created_by).toBe('usr_seed_author');
337+
expect(stored('by_none').created_by).toBeUndefined();
338+
339+
vi.setSystemTime(new Date(BOOT_2));
340+
await objectql.update(OBJECT, { id: row.id, subject: 'v2', created_by: 'usr_seed_author' }, { context: SEED_WRITE_EXECUTION_CONTEXT });
341+
expect(stored('by').created_by).toBe('usr_seed_author');
342+
expect(stored('by').updated_by).toBeUndefined();
343+
});
344+
});

0 commit comments

Comments
 (0)