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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/now-default-token-engine-resolved.md
Original file line number Diff line number Diff line change
@@ -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.
202 changes: 192 additions & 10 deletions packages/objectql/src/engine-default-value-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, Record<string, unknown>>>();
Expand Down Expand Up @@ -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');
});
});
Loading
Loading