From b7740c4d41a33e20743d715243e82a8eef57936a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:56:38 +0000 Subject: [PATCH] fix(objectql): resolve the `NOW()` defaultValue token in the engine (#4597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `applyFieldDefaults` special-cased exactly two `defaultValue` shapes — the Expression envelope and the `current_user` token — and passed everything else through verbatim, so `'NOW()'` was written into the record as a literal string. `SqlDriver.formatInput`'s safety net swapped it for a real timestamp before the wire; memory and mongodb have no such net. The mirror of #4560: there `current_user` was known to the engine and not to the DDL, so the DDL stored the token text. Here `NOW()` was known to the SQL driver and not to the engine. It surfaced two ways — a validated field was rejected by the engine's own write validator against a value the engine itself had filled in, and a `readonly`/`system` field (which validateRecord skips, i.e. the ~100 platform `created_at`/`updated_at` declarations) stored the four characters `NOW()` silently. The engine now resolves the token from the same per-insert `now` snapshot it already passes to Expression defaults, so every field defaulted in one insert — and every row of one batch — carries the identical instant. The spelling it matches is the spec's (`isNowDefaultToken`), the same predicate a driver's DDL consults; the engine does not re-derive its own. Resolution follows the field's declared type, which is what `SqlDriver.nowColumnDefault` already emits per type (ADR-0053), so no datasource disagrees about the stored form: `date` -> `YYYY-MM-DD`, `time` -> `HH:MM:SS[.fff]`, everything else -> `YYYY-MM-DDTHH:MM:SS.sssZ`. Both driver-side mechanisms stay unchanged as defence in depth: the `formatInput` safety net (now unreachable from this path) and the native column DEFAULT, which still serves engine-bypassing writes — the same division of labour `current_user` has. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NrmBxj8rK2uGCnh9aipjwX --- .../now-default-token-engine-resolved.md | 54 +++++ .../src/engine-default-value-tokens.test.ts | 202 +++++++++++++++++- packages/objectql/src/engine.ts | 85 +++++++- 3 files changed, 325 insertions(+), 16 deletions(-) create mode 100644 .changeset/now-default-token-engine-resolved.md diff --git a/.changeset/now-default-token-engine-resolved.md b/.changeset/now-default-token-engine-resolved.md new file mode 100644 index 0000000000..16e715a342 --- /dev/null +++ b/.changeset/now-default-token-engine-resolved.md @@ -0,0 +1,54 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): resolve the `NOW()` defaultValue token in the engine, so it works on every datasource (#4597) + +`Field.datetime({ defaultValue: 'NOW()' })` only ever worked on SQL. The engine's +`applyFieldDefaults` special-cased exactly two `defaultValue` shapes — the +Expression envelope and the `current_user` token — and passed everything else +through verbatim, so the four characters `NOW()` were written into the record as +a **literal string**. The SQL driver hid that: `SqlDriver.formatInput` carries an +insert-time safety net that swaps any `NOW()` string for a real ISO timestamp +before it hits the wire. Memory and MongoDB have no such net. + +This is the mirror image of #4560. There, `current_user` was known to the engine +and not to the DDL, so the DDL stored the token text. Here, `NOW()` was known to +the SQL driver and not to the engine — same crack, opposite side. It surfaced two +ways: + +- On a **validated** field the insert was **rejected outright**, by the engine's + own write validator, against a value the engine itself had just filled in: + `ValidationError: … must be a valid datetime (ISO-8601)`. Every insert omitting + such a field failed, with an error naming a field the caller never sent. +- On a `readonly` / `system` field — which `validateRecord` skips, i.e. the ~100 + `created_at` / `updated_at` declarations across the platform objects — nothing + was rejected at all and the string `NOW()` was **stored**. + +`applyFieldDefaults` now resolves the token itself, from the same per-insert +`now` snapshot it already passes to Expression defaults, so every field defaulted +in one insert (and every row of one batch) carries the identical instant. The +spelling it matches is the spec's (`isNowDefaultToken` from +`@objectstack/spec/data`, case-insensitive and whitespace tolerant), the same +predicate a driver's DDL consults — the engine does not re-derive its own. + +The token resolves into the shape the field's **declared type** stores, which is +what `SqlDriver.nowColumnDefault` already emits per type (ADR-0053), so no +datasource disagrees about the stored form: + +| field type | stored value | +|---|---| +| `date` | `YYYY-MM-DD` (UTC calendar day) | +| `time` | `HH:MM:SS[.fff]` (UTC wall clock; a zero `.000` is trimmed) | +| `datetime`, and any non-temporal field that opts in | `YYYY-MM-DDTHH:MM:SS.sssZ` | + +No authoring change: `defaultValue: 'NOW()'` is the same declaration it always +was, and a caller-supplied value is still never overwritten. What changes is that +it now means the same thing on memory and MongoDB as it always did on SQL. +Records written on a non-SQL datasource before this fix may hold the literal +string `NOW()` in those columns; they are not rewritten. + +Both driver-side mechanisms stay, unchanged, as defence in depth: `formatInput`'s +safety net (now unreachable from the engine's insert path) and the native column +DEFAULT, which still serves writes that bypass the engine entirely — the same +division of labour `current_user` has. diff --git a/packages/objectql/src/engine-default-value-tokens.test.ts b/packages/objectql/src/engine-default-value-tokens.test.ts index d9ee19eb0a..4ea1f4cadb 100644 --- a/packages/objectql/src/engine-default-value-tokens.test.ts +++ b/packages/objectql/src/engine-default-value-tokens.test.ts @@ -1,23 +1,37 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The engine half of the `defaultValue` runtime-token contract (#4560). + * The engine half of the `defaultValue` runtime-token contract (#4560, #4597). * - * `applyFieldDefaults` owns the `current_user` token: it stamps the acting - * user's id on insert, and with NO authenticated user (system / anonymous - * writes) it deliberately leaves the field UNSET rather than invent an owner. + * `applyFieldDefaults` owns the WHOLE token family (`DEFAULT_VALUE_TOKENS`): * - * That "leave it unset" is only worth anything if nothing downstream fills the - * gap behind the engine's back — which is exactly what a SQL column - * `DEFAULT 'current_user'` did (#4560). These tests pin the engine side of the - * agreement, and that the token spelling it matches is the SPEC's - * (`DEFAULT_VALUE_TOKENS`), the same set a driver's DDL consults when deciding + * - `current_user` — stamps the acting user's id on insert, and with NO + * authenticated user (system / anonymous writes) deliberately leaves the + * field UNSET rather than invent an owner. + * - `NOW()` — stamps the insert-time clock, in the storage shape the field's + * declared type calls for. + * + * Both halves are the same crack seen from opposite sides. `current_user` was + * known to the engine and not to the DDL, so a SQL column + * `DEFAULT 'current_user'` filled the gap the engine had deliberately left + * (#4560). `NOW()` was known to the SQL driver and not to the engine, so the + * literal four characters `NOW()` went to every driver — hidden on SQL by + * `formatInput`'s safety net, and on memory/mongodb either REJECTED by the + * engine's own write validator or, on a `readonly`/`system` field that + * `validateRecord` skips, stored verbatim (#4597). + * + * So these tests pin one property above all: a token is resolved by the ENGINE, + * identically for every datasource, and the spelling it matches is the SPEC's + * (`DEFAULT_VALUE_TOKENS`) — the same set a driver's DDL consults when deciding * which `defaultValue`s may become a physical column DEFAULT. */ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from './engine.js'; -import { DEFAULT_VALUE_TOKEN_CURRENT_USER } from '@objectstack/spec/data'; +import { DEFAULT_VALUE_TOKEN_CURRENT_USER, DEFAULT_VALUE_TOKEN_NOW } from '@objectstack/spec/data'; + +/** `YYYY-MM-DDTHH:MM:SS.sssZ` — the canonical stored instant (ADR-0053). */ +const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; function makeMemoryDriver() { const stores = new Map>>(); @@ -110,3 +124,171 @@ describe('[#4560] the `current_user` defaultValue token is engine-owned', () => expect(row.owner).toBe('CURRENT_USER'); }); }); + +describe('[#4597] the `NOW()` defaultValue token is engine-owned too', () => { + let engine: ObjectQL; + + const stamped = { + name: 'probe_now', + label: 'Probe', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + // The issue's exact repro: a plain (NOT readonly) datetime, so the + // engine's own write validator sees the value it just defaulted. + seen_at: { + name: 'seen_at', label: 'Seen At', type: 'datetime' as const, + defaultValue: DEFAULT_VALUE_TOKEN_NOW, + }, + }, + }; + + beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver().driver, true); + await engine.init(); + engine.registry.registerObject(stamped as any); + }); + + it('resolves on a NON-SQL datasource — the insert succeeds and stores a real instant', async () => { + // Before the fix this threw `ValidationError: seen_at must be a valid + // datetime (ISO-8601)`: the engine filled the field with the literal + // string 'NOW()' and its own validator then rejected the insert. The + // memory driver has no `formatInput` safety net to hide it behind. + const row: any = await engine.insert('probe_now', {}, { context: { isSystem: true } } as any); + expect(row.seen_at).not.toBe('NOW()'); + expect(row.seen_at).toMatch(ISO_INSTANT); + expect(Number.isNaN(Date.parse(row.seen_at))).toBe(false); + }); + + it('never overwrites a caller-supplied value', async () => { + const supplied = '2020-01-02T03:04:05.678Z'; + const row: any = await engine.insert( + 'probe_now', { title: 'A', seen_at: supplied }, { context: { isSystem: true } } as any, + ); + expect(row.seen_at).toBe(supplied); + }); + + it('an explicit null is treated as "not supplied" and still resolves the token (#2706)', async () => { + const row: any = await engine.insert( + 'probe_now', { title: 'B', seen_at: null }, { context: { isSystem: true } } as any, + ); + expect(row.seen_at).toMatch(ISO_INSTANT); + }); + + it('a `readonly` field stores the instant, not the four characters `NOW()`', async () => { + // The silent half of the bug, and the shape ~100 platform declarations use + // (`created_at` / `updated_at` are `readonly`). `validateRecord` SKIPS + // readonly fields, so nothing was rejected — the token text just landed in + // the column, the #4560 failure mode exactly. + // Deliberately the ONLY token-defaulted field on the object: with no + // validated sibling to throw first, an unresolved token raises NOTHING and + // the write "succeeds" — which is what makes this the silent face. + engine.registry.registerObject({ + name: 'probe_now_ro', + label: 'Probe RO', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + created_at: { + name: 'created_at', label: 'Created At', type: 'datetime' as const, + defaultValue: DEFAULT_VALUE_TOKEN_NOW, readonly: true, + }, + }, + } as any); + const row: any = await engine.insert('probe_now_ro', {}, { context: { isSystem: true } } as any); + expect(row.created_at).not.toBe('NOW()'); + expect(row.created_at).toMatch(ISO_INSTANT); + }); + + it('resolves ONCE per insert — every defaulted field on a record shares one instant', async () => { + engine.registry.registerObject({ + ...stamped, + name: 'probe_now_pair', + fields: { + ...stamped.fields, + also_at: { + name: 'also_at', label: 'Also At', type: 'datetime' as const, + defaultValue: DEFAULT_VALUE_TOKEN_NOW, + }, + }, + } as any); + const row: any = await engine.insert('probe_now_pair', {}, { context: { isSystem: true } } as any); + expect(row.seen_at).toMatch(ISO_INSTANT); + // Same `nowSnapshot`, so the two cannot straddle a millisecond boundary. + expect(row.also_at).toBe(row.seen_at); + }); + + it('one batch insert shares one instant across rows', async () => { + const rows: any[] = await engine.insert( + 'probe_now', [{ title: 'A' }, { title: 'B' }], { context: { isSystem: true } } as any, + ); + expect(rows).toHaveLength(2); + expect(rows[0].seen_at).toMatch(ISO_INSTANT); + expect(rows[1].seen_at).toBe(rows[0].seen_at); + }); + + it('matches the SPEC predicate, so the tolerant spellings the platform relies on resolve', async () => { + // `isNowDefaultToken` is case-insensitive and whitespace tolerant — the + // rule the SQL driver has always applied. The engine consumes that + // predicate rather than re-deriving a stricter local one, or the two sides + // would disagree about which declarations are tokens at all. + engine.registry.registerObject({ + ...stamped, + name: 'probe_now_lower', + fields: { ...stamped.fields, seen_at: { ...stamped.fields.seen_at, defaultValue: ' now() ' } }, + } as any); + const row: any = await engine.insert('probe_now_lower', {}, { context: { isSystem: true } } as any); + expect(row.seen_at).toMatch(ISO_INSTANT); + }); + + it('resolves into the shape the DECLARED TYPE stores — date and time are not instants', async () => { + // ADR-0053: a `Field.date` is a calendar day and a `Field.time` a wall + // clock. `SqlDriver.nowColumnDefault` already emits exactly these two + // shapes; stamping a full instant here would just move the cross-driver + // drift instead of closing it (SQL collapses it in `formatInput`, + // memory/mongodb would not). + engine.registry.registerObject({ + ...stamped, + name: 'probe_now_temporal', + fields: { + ...stamped.fields, + on_day: { + name: 'on_day', label: 'Day', type: 'date' as const, + defaultValue: DEFAULT_VALUE_TOKEN_NOW, + }, + at_time: { + name: 'at_time', label: 'Time', type: 'time' as const, + defaultValue: DEFAULT_VALUE_TOKEN_NOW, + }, + }, + } as any); + const row: any = await engine.insert('probe_now_temporal', {}, { context: { isSystem: true } } as any); + expect(row.on_day).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // `HH:MM:SS` or `HH:MM:SS.fff` — a zero-millisecond `.000` is trimmed so + // the row stays byte-canonical against an equality filter. + expect(row.at_time).toMatch(/^\d{2}:\d{2}:\d{2}(\.\d{3})?$/); + expect(row.at_time).not.toMatch(/\.000$/); + // All three are cut from the one snapshot: the day and the UTC wall clock + // of the very same instant. + expect(row.on_day).toBe(row.seen_at.slice(0, 10)); + const timeOfDay: string = row.seen_at.slice(11, 23); + expect(row.at_time).toBe(timeOfDay.endsWith('.000') ? timeOfDay.slice(0, 8) : timeOfDay); + }); + + it('a NEAR-MISS spelling is a literal, not a token', async () => { + engine.registry.registerObject({ + ...stamped, + name: 'probe_now_typo', + // `type: 'text'` so the literal is storable — the point is that the + // engine did not treat `NOW` as the token, not what a datetime does + // with it. + fields: { + ...stamped.fields, + seen_at: { name: 'seen_at', label: 'Seen At', type: 'text' as const, defaultValue: 'NOW' }, + }, + } as any); + const row: any = await engine.insert('probe_now_typo', {}, { context: { isSystem: true } } as any); + expect(row.seen_at).toBe('NOW'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 63663c14b4..400ddde561 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -17,7 +17,7 @@ import { type DroppedFieldsEvent } from '@objectstack/spec/data'; import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, @@ -1437,6 +1437,50 @@ export class ObjectQL implements IObjectQLEngine { return opts; } + /** + * Resolve the `NOW()` runtime token into the value the field's declared type + * actually stores (#4597). + * + * The engine — not the driver — owns this resolution, exactly as it owns + * `current_user`. `NOW()` is the mirror of #4560's crack: `current_user` was + * known to the engine and not to the DDL, so the DDL stored the token text; + * `NOW()` was known to the SQL driver and not to the engine, so every + * non-SQL datasource got the token text instead of a time. Resolving here + * makes one answer serve every driver. + * + * The shapes below are NOT invented: they are the canonical storage forms + * ADR-0053 already fixed and `SqlDriver.nowColumnDefault` already emits + * per type. Producing a full instant for a `Field.date` would trade the old + * cross-driver drift for a new one (SQL would keep collapsing it to a + * calendar day in `formatInput`, memory/mongodb would not), so the token + * resolves per declared type: + * + * | field type | stored form | matches | + * |---|---|---| + * | `date` | `YYYY-MM-DD` (UTC day) | `toDateOnly` / the `date` column DEFAULT | + * | `time` | `HH:MM:SS[.fff]` (UTC wall clock, `.000` trimmed) | `canonicalTimeOfDay` | + * | anything else | `YYYY-MM-DDTHH:MM:SS.sssZ` | `canonicalUtcDatetime` | + * + * "Anything else" deliberately includes non-temporal fields: a `text` field + * that opts into `NOW()` gets the instant, which is what the SQL column + * DEFAULT gives it today. + * + * `now` is the caller's per-insert snapshot, so two defaulted fields on one + * record cannot straddle a millisecond boundary. + */ + private resolveNowDefault(fieldType: unknown, now: Date): string { + const iso = now.toISOString(); // YYYY-MM-DDTHH:MM:SS.sssZ + if (fieldType === 'date') return iso.slice(0, 10); + if (fieldType === 'time') { + const timeOfDay = iso.slice(11, 23); // HH:MM:SS.fff + // Trim a zero-millisecond `.000` so a defaulted row is byte-canonical and + // still matches an equality filter against `'HH:MM:SS'` — the same trim + // the SQL driver's time DEFAULT and `canonicalTimeOfDay` apply. + return timeOfDay.endsWith('.000') ? timeOfDay.slice(0, 8) : timeOfDay; + } + return iso; + } + /** * Build a HookContext.api: a ScopedContext that hooks can use to * read/write other objects within the same execution context. @@ -1452,9 +1496,12 @@ export class ObjectQL implements IObjectQLEngine { * Apply field defaults to an incoming insert payload. Defaults that are * Expression envelopes (e.g. `{ dialect: 'cel', source: 'today()' }`, * `{ dialect: 'cel', source: 'os.user.id' }`) are evaluated via - * `ExpressionEngine` against the calling user/org/now snapshot. Static - * defaults are applied verbatim. Records that already supplied a value for a - * field are left untouched. + * `ExpressionEngine` against the calling user/org/now snapshot. The + * `defaultValue` runtime TOKENS (`@objectstack/spec/data`'s + * `DEFAULT_VALUE_TOKENS` — `current_user` and `NOW()`, the whole family) are + * resolved here, so one declaration behaves identically on every driver. + * Static defaults are applied verbatim. Records that already supplied a value + * for a field are left untouched. * * "Supplied a value" means the field is present with a non-null value. Both an * OMITTED field (`undefined`) and an EXPLICIT `null` are treated as "not @@ -1478,7 +1525,7 @@ export class ObjectQL implements IObjectQLEngine { const fieldsRaw = (schema as any)?.fields; if (!fieldsRaw || typeof fieldsRaw !== 'object') return record; // `fields` may be a Record (canonical) or an array (legacy). - const fieldEntries: Array<{ name: string; defaultValue?: unknown }> = Array.isArray(fieldsRaw) + const fieldEntries: Array<{ name: string; type?: unknown; defaultValue?: unknown }> = Array.isArray(fieldsRaw) ? fieldsRaw : Object.entries(fieldsRaw).map(([name, def]) => ({ name, ...(def as object) })); const out = { ...record }; @@ -1522,6 +1569,31 @@ export class ObjectQL implements IObjectQLEngine { // SQL emitted `DEFAULT 'current_user'` and the DATABASE overrode the // "leave it unset" decision below with a literal non-id (#4560). if (execCtx?.userId != null) out[f.name] = String(execCtx.userId); + } else if (isNowDefaultToken(dv)) { + // `NOW()` token → the insert-time clock, in the storage shape the + // field's declared type calls for ({@link resolveNowDefault}). + // + // The mirror of the `current_user` crack above (#4597 / #4560): this + // token was understood by the SQL driver and NOT by the engine, so + // `out[f.name] = dv` sent the literal string `'NOW()'` to every + // driver. SQL hid it — `formatInput`'s safety net swapped in a real + // timestamp before the wire — while memory/mongodb have no such net, + // so the same declaration behaved differently per datasource. That + // split surfaced two ways: a validation-visible field was REJECTED by + // the engine's own write validator ("must be a valid datetime"), and a + // `readonly`/`system` field — which `validateRecord` skips, i.e. the + // ~100 `created_at`/`updated_at` platform declarations — silently + // stored the four characters `NOW()`. + // + // Resolved from the caller's `nowSnapshot`, so every defaulted field + // in one insert (and every row of one batch) carries the SAME instant. + // + // The driver's own now-handling is unchanged and stays as defence in + // depth: `SqlDriver.formatInput`'s safety net (now unreachable from + // this path) and the native column DEFAULT, which still serves writes + // that bypass the engine entirely — the same division of labour + // `current_user` has. + out[f.name] = this.resolveNowDefault(f.type, now); } else { out[f.name] = dv; } @@ -4471,7 +4543,8 @@ export class ObjectQL implements IObjectQLEngine { }; await this.executeWithMiddleware(opCtx, async () => { - // Resolve field `defaultValue`s (including the `current_user` token) + // Resolve field `defaultValue`s (including the `current_user` and + // `NOW()` tokens) // BEFORE the beforeInsert hook runs, so a hook that DERIVES one field // from another can read the defaulted value instead of a stale `null` // (#2703). The hook still has final say — it runs after and may override