From 2a8264570c2208f1337d3769c493bbe1e6ea1187 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 00:42:46 +0000 Subject: [PATCH 1/2] fix(objectql): a seed row keeps its authored created_at on insert, as the replay already does The built-in beforeInsert audit stamp kept a supplied created_at only under preserveAudit, so a seed write (SEED_WRITE_EXECUTION_CONTEXT, which carries seedReplay and no preserveAudit) had its authored created_at replaced with the boot instant on insert, while the upsert update of a later boot wrote the authored value. The insert stamp now keeps an authored created_at under seedReplay too, read from the beforeInsert envelope's input.options.context (the hook session carries no seedReplay). Bare isSystem, REST and every other caller still stamp now; preserveAudit and its insert warning are unchanged. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21646-seed-created-at.md | 12 + .../src/plugin-audit-seed-created-at.test.ts | 342 ++++++++++++++++++ packages/objectql/src/plugin.ts | 37 +- 3 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 .changeset/21646-seed-created-at.md create mode 100644 packages/objectql/src/plugin-audit-seed-created-at.test.ts diff --git a/.changeset/21646-seed-created-at.md b/.changeset/21646-seed-created-at.md new file mode 100644 index 00000000000..1d1f55715e0 --- /dev/null +++ b/.changeset/21646-seed-created-at.md @@ -0,0 +1,12 @@ +--- +"@objectstack/objectql": patch +--- + +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). + +Clause-②: no + +- **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)` ``. +- **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()`. +- **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. +- ⛔ No schema, key, export or error code is added or removed. diff --git a/packages/objectql/src/plugin-audit-seed-created-at.test.ts b/packages/objectql/src/plugin-audit-seed-created-at.test.ts new file mode 100644 index 00000000000..8f53855aa04 --- /dev/null +++ b/packages/objectql/src/plugin-audit-seed-created-at.test.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #21646 — a seed row's authored `created_at` is KEPT on the INSERT of a fresh +// boot, exactly as the upsert UPDATE of a later boot already keeps it. +// +// ## The defect, as measured on a real app's seed (17.6.0) +// +// seed row authored created_at fresh boot (INSERT) second boot (UPDATE) +// -------- -------------------------------- -------------------- --------------------- +// A cel`daysAgo(5)` boot instant the authored value +// B '2026-09-01T12:00:00.000Z' boot instant the authored value +// control none boot instant unchanged +// +// The audit binder's `beforeInsert` stamp (`sys_stamp_audit_insert`) kept a +// supplied `created_at` only under `preserveAudit`, and the seed write context +// (`SEED_WRITE_EXECUTION_CONTEXT` = `{ isSystem, skipTriggers, seedReplay }`) +// carries no `preserveAudit`. The `beforeUpdate` stamp touches `updated_at` +// only, and a seed write is `isSystem`, so the readonly strip never ran on the +// replay either — the authored value was written there. One value, two paths, +// two outcomes. +// +// ## Ruling (triage, verbatim from "Ruling" to the pins) +// +// > **Ruling: key the insert stamp on `seedReplay`, not on `preserveAudit`.** +// > - `preserveAudit` is "UPDATE-only … never when it is created" … ⛔ So the +// > fix does **not** add `preserveAudit` to the seed context. +// > - So under `seedReplay` the insert stamp keeps an authored `created_at` +// > (`?? now`), as the replay already does. +// > - ⛔ Not under bare `isSystem`: a system clone could carry a source row's +// > `created_at`. +// > - ⛔ No change for REST or any other caller. +// +// The four pins below are the ruling's four, on the real seed boot path: +// `SeedLoaderService.load()` through the kernel's own ObjectQL engine, with +// `ObjectQLPlugin`'s audit hooks bound exactly as a booted app binds them. A +// "boot" is one `load()` over the same store; the second one is the replay. +// +// ## Where the stamp reads `seedReplay` +// +// The hook's `session` (engine `buildSession`) carries `isSystem`, the skip +// flags and `preserveAudit` — NOT `seedReplay`. The `beforeInsert` envelope's +// `input.options` IS the caller's own options bag (HookContext `input` PHASE +// contract, `packages/spec/src/data/hook.zod.ts`), so its `context` is the +// write's ExecutionContext as the seeder built it. That is where the stamp +// reads it, and every seed reader passes the one shared constant there. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { cel } from '@objectstack/spec'; +import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; +import { SeedLoaderService, ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { ObjectQLPlugin } from './plugin.js'; +import { ObjectQL } from './engine.js'; +import { preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; + +const OBJECT = 'seed_case'; +/** Boot instants. Same UTC day, so `daysAgo(5)` names one calendar day on both. */ +const BOOT_1 = '2026-10-03T12:00:00.000Z'; +const BOOT_2 = '2026-10-03T13:00:00.000Z'; +/** `daysAgo(5)` at either boot: UTC midnight of the calendar day five days back. */ +const DAYS_AGO_5 = '2026-09-28T00:00:00.000Z'; +const AUTHORED_B = '2026-09-01T12:00:00.000Z'; +const FORGED = '1999-01-01T00:00:00.000Z'; + +/** A store-backed stub driver: the stored row IS the verdict. */ +function makeStubDriver() { + const stores = new Map>(); + const storeFor = (o: string) => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + const checkOp = (value: any, cond: any): boolean => { + if (cond === null || typeof cond !== 'object' || Array.isArray(cond) || cond instanceof Date) { + return value === cond; + } + return Object.entries(cond).every(([op, target]: [string, any]) => { + switch (op) { + case '$eq': return value === target; + case '$ne': return value !== target; + case '$in': return Array.isArray(target) && target.includes(value); + // REFUSE, never silently match — an unsupported operator answered + // `true` would read as a hit for every row. + default: throw new Error(`stub driver: unsupported operator ${op}`); + } + }); + }; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]: [string, any]) => { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k.startsWith('$')) throw new Error(`stub driver: unsupported combinator ${k}`); + return checkOp(row?.[k], v); + }); + }; + let n = 0; + const driver: any = { + name: 'seed-store', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r }; + return null; + }, + async create(object: string, data: Record) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return { ...row }; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const row = { ...s.get(id), ...data, id }; + s.set(id, row); + return { ...row }; + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).length; + }, + }; + return { driver, storeFor }; +} + +function emptyMetadata() { + return { + getObject: async () => undefined, + listObjects: async () => [], + register: async () => {}, + get: async () => undefined, + list: async () => [], + unregister: async () => {}, + exists: async () => false, + listNames: async () => [], + }; +} + +const quietLogger = { info() {}, warn() {}, error() {}, debug() {} }; + +const LOAD_CONFIG = { + dryRun: false, + haltOnError: false, + multiPass: true, + defaultMode: 'upsert', + batchSize: 1000, + transaction: false, +} as any; + +/** The card's three rows; `subject` differs per boot so the replay is a real UPDATE. */ +const seedFor = (subject: string) => ({ + object: OBJECT, + externalId: 'code', + mode: 'upsert', + env: ['prod', 'dev', 'test'], + records: [ + { code: 'A', subject, created_at: cel`daysAgo(5)` }, + { code: 'B', subject, created_at: AUTHORED_B }, + { code: 'C', subject }, + ], +}); + +/** An instant, read the same way whether the driver was handed a `Date` or a string. */ +const instant = (v: unknown) => new Date(v as any).toISOString(); + +describe('audit binder: a seed row keeps its authored `created_at` (#21646)', () => { + let kernel: ObjectKernel; + let objectql: ObjectQL; + let storeFor: ReturnType['storeFor']; + let warns: string[]; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(BOOT_1)); + kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + const stub = makeStubDriver(); + storeFor = stub.storeFor; + await kernel.use({ + name: 'seed-store-plugin', type: 'driver', version: '1.0.0', + init: async (ctx: any) => { ctx.registerService('driver.seed-store', stub.driver); }, + } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + objectql = kernel.getService('objectql'); + // `created_at` / `created_by` are NOT declared: the registry injects them + // from `AUDIT_FIELD_DEFS` (`readonly: true`), as on every real object. + objectql.registry.registerObject({ + name: OBJECT, + label: 'Seed Case', + datasource: 'seed-store', + fields: { + code: { name: 'code', label: 'Code', type: 'text' }, + subject: { name: 'subject', label: 'Subject', type: 'text' }, + run_at: { name: 'run_at', label: 'Run At', type: 'datetime', readonly: true }, + }, + } as any, 'test', 'test'); + warns = []; + const engineLogger = (objectql as any).logger; + vi.spyOn(engineLogger, 'warn').mockImplementation((...a: unknown[]) => { warns.push(String(a[0])); }); + }); + + afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + if (kernel.getState() === 'running') await kernel.shutdown(); + }); + + const loader = () => new SeedLoaderService(objectql as never, emptyMetadata() as never, quietLogger as never); + const boot = async (at: string, subject: string) => { + vi.setSystemTime(new Date(at)); + const result = await loader().load({ seeds: [seedFor(subject)] as never, config: LOAD_CONFIG }); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + return result; + }; + const stored = (code: string) => { + const rows = Array.from(storeFor(OBJECT).values()).filter((r) => r.code === code); + expect(rows.length, `exactly one stored row for code ${code}`).toBe(1); + return rows[0]; + }; + + // ── Pin 1 ──────────────────────────────────────────────────────────────── + it('pin 1: an authored `created_at` is kept on the fresh boot (INSERT) and on the replay (UPDATE)', async () => { + const fresh = await boot(BOOT_1, 'v1'); + expect(fresh.summary.totalInserted).toBe(3); + + // The card's two rows. A `cel` value is evaluated by the loader BEFORE the + // write decision, so the INSERT is handed the instant, never the envelope. + expect(instant(stored('A').created_at)).toBe(DAYS_AGO_5); + expect(instant(stored('B').created_at)).toBe(AUTHORED_B); + + const replay = await boot(BOOT_2, 'v2'); + // A real UPDATE of every row, not a skipped no-op replay. + expect(replay.summary.totalUpdated).toBe(3); + expect(stored('A').subject).toBe('v2'); + + expect(instant(stored('A').created_at)).toBe(DAYS_AGO_5); + expect(instant(stored('B').created_at)).toBe(AUTHORED_B); + // The replay is still an UPDATE as far as the binder is concerned: the + // last-modified stamp moves to the second boot. + expect(stored('A').updated_at).toBe(BOOT_2); + expect(stored('B').updated_at).toBe(BOOT_2); + }); + + // The three seed readers pass one constant, `SEED_WRITE_EXECUTION_CONTEXT`. + // `SeedLoaderService` is pin 1. `AppPlugin`'s fallback replay of a stack's + // `data[]` calls `ql.insert(object, record, { context: })` per row, and + // `@objectstack/verify`'s `seed()` calls `ql.insert(object, rows, { context: + // })` with an array — both shapes, through the same engine. + it('pin 1, the other two readers\' call shapes: a single-row and an array insert under the seed context keep it too', async () => { + await objectql.insert(OBJECT, { code: 'single', created_at: AUTHORED_B }, { context: SEED_WRITE_EXECUTION_CONTEXT }); + await objectql.insert( + OBJECT, + [{ code: 'arr0', created_at: AUTHORED_B }, { code: 'arr1' }], + { context: SEED_WRITE_EXECUTION_CONTEXT }, + ); + + expect(stored('single').created_at).toBe(AUTHORED_B); + expect(stored('arr0').created_at).toBe(AUTHORED_B); + expect(stored('arr1').created_at).toBe(BOOT_1); + }); + + // ── Pin 2 ──────────────────────────────────────────────────────────────── + it('pin 2: a seed row with no `created_at` is stamped at boot, and the replay leaves that stamp alone', async () => { + await boot(BOOT_1, 'v1'); + // Stamped by the binder: the hook ran under `skipTriggers`, which + // suppresses flow dispatch, never the code-registered audit hooks. + expect(stored('C').created_at).toBe(BOOT_1); + + await boot(BOOT_2, 'v2'); + expect(stored('C').created_at).toBe(BOOT_1); + expect(stored('C').updated_at).toBe(BOOT_2); + }); + + // ── Pin 3 ──────────────────────────────────────────────────────────────── + it('pin 3: a non-seed system insert and a REST insert are both stamped now', async () => { + // System contexts WITHOUT `seedReplay`: bare elevation, and the seed + // posture minus its one load-bearing flag. + await objectql.insert(OBJECT, { code: 'sys', created_at: FORGED }, { context: { isSystem: true } }); + await objectql.insert( + OBJECT, + { code: 'sys_quiet', created_at: FORGED }, + { context: { isSystem: true, skipTriggers: true } }, + ); + // The REST data door: `POST /api/v1/data/OBJECT` reaches the engine through + // the protocol's `createData` with the caller's assembled context. + const protocol = new ObjectStackProtocolImplementation(objectql as never); + await protocol.createData({ object: OBJECT, data: { code: 'rest', created_at: FORGED }, context: { userId: 'user-1' } }); + + expect(stored('sys').created_at).toBe(BOOT_1); + expect(stored('sys_quiet').created_at).toBe(BOOT_1); + expect(stored('rest').created_at).toBe(BOOT_1); + }); + + // ── Pin 4 ──────────────────────────────────────────────────────────────── + it('pin 4: the `preserveAudit` insert warning is unchanged — it fires for a non-system create, never for a seed insert', async () => { + // The lead clause, derived from the producer rather than spelled here. + const lead = preserveAuditIgnoredOnInsertWarning('', []).split(':')[0]; + + await boot(BOOT_1, 'v1'); + await boot(BOOT_2, 'v2'); + expect(warns.filter((w) => w.startsWith(lead))).toEqual([]); + + await objectql.insert( + OBJECT, + { code: 'hist', run_at: FORGED, created_at: FORGED }, + { context: { userId: 'user-1', preserveAudit: true } }, + ); + expect(warns.filter((w) => w.startsWith(lead))).toEqual([ + preserveAuditIgnoredOnInsertWarning(OBJECT, ['run_at']), + ]); + // The historical-import channel itself is untouched: the binder keeps the + // authored `created_at` under `preserveAudit`, the strip takes `run_at`. + expect(stored('hist').created_at).toBe(FORGED); + expect(stored('hist').run_at).toBeUndefined(); + }); + + // ── `created_by`, measured (a companion, not a pin) ────────────────────── + // The seed context carries no `userId`, and the binder assigns the audit + // user fields only inside `if (session?.userId)`, on either event. So an + // authored `created_by` is untouched on the INSERT and on the UPDATE, and an + // unauthored one stays absent. This was already true before #21646 and the + // change does not touch it — green on both sides, so it is evidence of the + // measurement, not of the fix. + it('companion: an authored `created_by` is kept on the seed INSERT and the seed UPDATE; none is stamped', async () => { + const [row] = await objectql.insert( + OBJECT, + [{ code: 'by', created_by: 'usr_seed_author' }, { code: 'by_none' }], + { context: SEED_WRITE_EXECUTION_CONTEXT }, + ); + expect(stored('by').created_by).toBe('usr_seed_author'); + expect(stored('by_none').created_by).toBeUndefined(); + + vi.setSystemTime(new Date(BOOT_2)); + await objectql.update(OBJECT, { id: row.id, subject: 'v2', created_by: 'usr_seed_author' }, { context: SEED_WRITE_EXECUTION_CONTEXT }); + expect(stored('by').created_by).toBe('usr_seed_author'); + expect(stored('by').updated_by).toBeUndefined(); + }); +}); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index f2969e51e7e..e34d4989074 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -1124,6 +1124,7 @@ export class ObjectQLPlugin implements Plugin { objectName: string, session: any, isInsert: boolean, + seedReplay: boolean, ) => { const now = stamp(); // A "historical" import (#3493) reinstates the ORIGINAL timeline, so a @@ -1145,9 +1146,19 @@ export class ObjectQLPlugin implements Plugin { // the same payload were taken. Under `preserveAudit` the preservation is // DECLARED, so the same keep is the ruled historical-import channel and // stays — which is why the fix is this ternary and not a bare `= now`. + // + // [#21646] A SEED write keeps an authored `created_at` on insert too — + // the same `?? now` — because a seed is a snapshot of established facts + // (`ExecutionContext.seedReplay`), and the replay of the same row on a + // later boot already writes that value through the update path, which + // never touches `created_at`. Keyed on `seedReplay` alone: ⛔ not on + // `isSystem` (a system clone could carry its source row's `created_at`), + // and ⛔ the seed context gains no `preserveAudit` (the 2026-08-08 ruling + // keeps that flag's readonly exemption UPDATE-only). Every other caller + // — REST included — still stamps now. const preserveAudit = session?.preserveAudit === true; if (isInsert) { - record.created_at = preserveAudit ? (record.created_at ?? now) : now; + record.created_at = preserveAudit || seedReplay ? (record.created_at ?? now) : now; } record.updated_at = preserveAudit ? (record.updated_at ?? now) : now; // [#16311] `created_by` takes the SAME SHAPE as `updated_by`, one field @@ -1190,18 +1201,33 @@ export class ObjectQLPlugin implements Plugin { objectName: string, session: any, isInsert: boolean, + seedReplay: boolean, ) => { if (Array.isArray(data)) { for (const row of data) { if (row && typeof row === 'object') { - applyToRecord(row as Record, objectName, session, isInsert); + applyToRecord(row as Record, objectName, session, isInsert, seedReplay); } } } else if (data && typeof data === 'object') { - applyToRecord(data as Record, objectName, session, isInsert); + applyToRecord(data as Record, objectName, session, isInsert, seedReplay); } }; + /** + * [#21646] Whether this `beforeInsert` is a SEED write + * (`ExecutionContext.seedReplay`, set through `SEED_WRITE_EXECUTION_CONTEXT` + * by every seeder). Read from `input.options.context`, not `session`: the + * engine's `buildSession` copies `isSystem`, the skip flags and + * `preserveAudit` onto the hook session, never `seedReplay`, while a + * `before*` envelope's `input.options` IS the caller's own options bag + * (HookContext `input` PHASE contract), so its `context` is the write's + * ExecutionContext as the seeder built it. Server-constructed, like + * `isSystem`: no transport entry point assembles it from a request. + */ + const isSeedReplay = (hookCtx: any): boolean => + hookCtx?.input?.options?.context?.seedReplay === true; + const builtinHooks: any[] = [ { name: 'sys_stamp_audit_insert', @@ -1211,7 +1237,7 @@ export class ObjectQLPlugin implements Plugin { description: 'Auto-stamp created_by / updated_by / created_at / updated_at / tenant_id on insert (only when the field exists on the object schema)', handler: async (hookCtx: any) => { if (hookCtx.input?.data) { - stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, true); + stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, true, isSeedReplay(hookCtx)); } }, }, @@ -1223,7 +1249,8 @@ export class ObjectQLPlugin implements Plugin { description: 'Auto-stamp updated_by / updated_at on update (only when the field exists on the object schema)', handler: async (hookCtx: any) => { if (hookCtx.input?.data) { - stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, false); + // `seedReplay` is moot here: the update stamp never writes `created_at`. + stampData(hookCtx.input.data, hookCtx.object, hookCtx.session, false, false); } }, }, From f135b5c542c1959f7d9ddada6aac7203d413ea44 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:03:36 +0000 Subject: [PATCH 2/2] test(objectql): the seed created_at pin's stub driver applies the caller's limit check:objectql-double-limit flagged the new stub driver's find as limit-blind; it now applies ast.limit after the filter, by presence. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/objectql/src/plugin-audit-seed-created-at.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/plugin-audit-seed-created-at.test.ts b/packages/objectql/src/plugin-audit-seed-created-at.test.ts index 8f53855aa04..c4a544169e7 100644 --- a/packages/objectql/src/plugin-audit-seed-created-at.test.ts +++ b/packages/objectql/src/plugin-audit-seed-created-at.test.ts @@ -100,7 +100,9 @@ function makeStubDriver() { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async syncSchema() {}, async find(object: string, ast: any) { - return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map((r) => ({ ...r })); + const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + const page = typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + return page.map((r) => ({ ...r })); }, async findOne(object: string, ast: any) { for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return { ...r };