Skip to content

Commit 94a5986

Browse files
committed
fix(#4560): defaultValue runtime tokens never become a column DEFAULT
The SQL DDL passed any non-object `defaultValue` through to `col.defaultTo(dv)`, so `Field.user({ defaultValue: 'current_user' })` was created as `DEFAULT 'current_user'` and the DATABASE stamped the literal token into every insert the engine had deliberately left unset (system/anonymous writes) — a non-id in a `lookup('sys_user')` column, found by #4551's dangling-reference audit. Declare the token family once in `@objectstack/spec/data` (`DEFAULT_VALUE_TOKENS` + predicates) so the engine's insert-time resolution and the driver's DDL read one set: `'NOW()'` keeps its driver-native default, every other token emits none, literals and Expression envelopes are unchanged. Column-default emission is now a single `applyDeclaredColumnDefault` shared by createColumn and the SQLite table rebuild. Existing databases are corrected through the managed schema-drift path: a `default_mismatch` finding with a `safe` `drop_column_default` op (ALTER … DROP DEFAULT on pg/mysql, table rebuild on SQLite, which now re-materializes sibling defaults from metadata). Rows already holding the bogus value are NOT rewritten — #4551's report-never-rewrite rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent ce92674 commit 94a5986

11 files changed

Lines changed: 848 additions & 31 deletions
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/driver-sql": patch
5+
---
6+
7+
fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560)
8+
9+
`Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at
10+
insert time, from the request's `ExecutionContext` — and with no authenticated
11+
user (system / anonymous writes: seed replay, package install, boot
12+
provisioning) `applyFieldDefaults` deliberately leaves the field **unset**
13+
rather than stamp a bogus owner.
14+
15+
The SQL DDL had never heard of the token. `createColumn` passed any non-object
16+
`defaultValue` straight through to `col.defaultTo(dv)`, so the column was
17+
created as `DEFAULT 'current_user'` and the **database** overrode the engine's
18+
decision: every insert that omitted the field stored the literal string
19+
`current_user` in a `lookup('sys_user')` column — a value that is not any user's
20+
id. `?expand` resolves it to nothing, and on an owner / approver field it is a
21+
silent mis-attribution. Found by #4551's dangling-reference audit on its first
22+
run against a real boot; #4441's referential check could never have caught it,
23+
because it inspects the values a **caller** supplied and here nobody supplied
24+
one.
25+
26+
**The token vocabulary is now declared once, in `@objectstack/spec/data`**
27+
(`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`,
28+
`isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's
29+
insert-time resolution and the driver's DDL read the same set, which is the
30+
actual defect: `'NOW()'` was special-cased in the branch immediately above for
31+
precisely this reason, and `current_user` — the same convention family — simply
32+
had no entry anywhere the DDL could see. A token added to the set tomorrow is
33+
excluded from literal column DEFAULTs automatically, rather than leaking its own
34+
spelling into the database the way this one did.
35+
36+
**DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation
37+
and the SQLite table rebuild):
38+
39+
- `'NOW()'` → the driver-native canonical default, exactly as before;
40+
- any other runtime token → **no column default at all** (the engine owns it);
41+
- Expression envelopes (`{ dialect, source }`) → unchanged, no default;
42+
- a real literal → emitted verbatim, unchanged.
43+
44+
**Existing databases carry the wrong DEFAULT**, so it is corrected through the
45+
managed schema-drift path (#2186) rather than a bespoke migration: a new
46+
`default_mismatch` finding with a `drop_column_default` op, categorised `safe`
47+
(the statement cannot fail and touches no rows). Dev boots with
48+
`autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported
49+
with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use
50+
`ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place,
51+
goes through the existing table rebuild — which now re-materialises every
52+
column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column
53+
keeps the default it always had instead of losing it to the rebuild.
54+
55+
**Rows already holding the bogus value are NOT rewritten.** That is #4551's
56+
standing rule — report, never rewrite — so they stay visible to the
57+
dangling-reference audit for operators to resolve deliberately.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The engine half of the `defaultValue` runtime-token contract (#4560).
5+
*
6+
* `applyFieldDefaults` owns the `current_user` token: it stamps the acting
7+
* user's id on insert, and with NO authenticated user (system / anonymous
8+
* writes) it deliberately leaves the field UNSET rather than invent an owner.
9+
*
10+
* That "leave it unset" is only worth anything if nothing downstream fills the
11+
* gap behind the engine's back — which is exactly what a SQL column
12+
* `DEFAULT 'current_user'` did (#4560). These tests pin the engine side of the
13+
* agreement, and that the token spelling it matches is the SPEC's
14+
* (`DEFAULT_VALUE_TOKENS`), the same set a driver's DDL consults when deciding
15+
* which `defaultValue`s may become a physical column DEFAULT.
16+
*/
17+
18+
import { describe, it, expect, beforeEach } from 'vitest';
19+
import { ObjectQL } from './engine.js';
20+
import { DEFAULT_VALUE_TOKEN_CURRENT_USER } from '@objectstack/spec/data';
21+
22+
function makeMemoryDriver() {
23+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
24+
const storeFor = (obj: string) => {
25+
let s = stores.get(obj);
26+
if (!s) { s = new Map(); stores.set(obj, s); }
27+
return s;
28+
};
29+
let nextId = 0;
30+
const driver: any = {
31+
name: 'memory', version: '0.0.0', supports: {} as any,
32+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
33+
async execute() { return null; },
34+
async find(object: string) { return Array.from(storeFor(object).values()); },
35+
findStream() { throw new Error('not implemented'); },
36+
async findOne(object: string) { return storeFor(object).values().next().value ?? null; },
37+
async create(object: string, data: Record<string, unknown>) {
38+
nextId += 1;
39+
const id = (data.id as string) ?? `r_${nextId}`;
40+
const row = { ...data, id };
41+
storeFor(object).set(id, row);
42+
return row;
43+
},
44+
async update() { return null; },
45+
async upsert(object: string, data: Record<string, unknown>) { return this.create(object, data); },
46+
async delete() { return true; },
47+
async count(object: string) { return storeFor(object).size; },
48+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
49+
return Promise.all(rows.map((r) => this.create(object, r)));
50+
},
51+
async bulkUpdate() { return []; },
52+
async bulkDelete() {},
53+
async updateMany() { return 0; },
54+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
55+
async commit() {}, async rollback() {},
56+
};
57+
return { driver, stores };
58+
}
59+
60+
const owned = {
61+
name: 'tok_doc',
62+
label: 'Doc',
63+
fields: {
64+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
65+
title: { name: 'title', label: 'Title', type: 'text' as const },
66+
owner: {
67+
name: 'owner', label: 'Owner', type: 'user' as const,
68+
reference: 'sys_user', defaultValue: DEFAULT_VALUE_TOKEN_CURRENT_USER,
69+
},
70+
},
71+
};
72+
73+
describe('[#4560] the `current_user` defaultValue token is engine-owned', () => {
74+
let engine: ObjectQL;
75+
76+
beforeEach(async () => {
77+
engine = new ObjectQL();
78+
engine.registerDriver(makeMemoryDriver().driver, true);
79+
await engine.init();
80+
engine.registry.registerObject(owned as any);
81+
});
82+
83+
it('stamps the acting user id on an authenticated insert', async () => {
84+
const row: any = await engine.insert('tok_doc', { title: 'A' }, { context: { userId: 'usr_7' } } as any);
85+
expect(row.owner).toBe('usr_7');
86+
});
87+
88+
it('leaves the field UNSET on a system/anonymous insert — never the literal token', async () => {
89+
// The seed-replay / package-install / boot-provisioning shape. This is the
90+
// decision a column DEFAULT used to override, writing the literal string
91+
// `current_user` into a lookup('sys_user') column (#4560).
92+
const row: any = await engine.insert('tok_doc', { title: 'B' }, { context: { isSystem: true } } as any);
93+
expect(row.owner).toBeUndefined();
94+
expect(row.owner).not.toBe('current_user');
95+
});
96+
97+
it('an explicit null is treated as "not supplied" and still resolves the token (#2706)', async () => {
98+
const row: any = await engine.insert('tok_doc', { title: 'C', owner: null }, { context: { userId: 'usr_9' } } as any);
99+
expect(row.owner).toBe('usr_9');
100+
});
101+
102+
it('a NEAR-MISS spelling is a literal, not a token — it is an authoring error, not an alias', async () => {
103+
engine.registry.registerObject({
104+
...owned,
105+
name: 'tok_typo',
106+
fields: { ...owned.fields, owner: { ...owned.fields.owner, defaultValue: 'CURRENT_USER' } },
107+
} as any);
108+
const row: any = await engine.insert('tok_typo', { title: 'D' }, { context: { userId: 'usr_1' } } as any);
109+
// Deliberately NOT resolved: widening the match would make a genuinely
110+
// intended literal unstorable. Lint catches the typo at authoring time.
111+
expect(row.owner).toBe('CURRENT_USER');
112+
});
113+
});

packages/objectql/src/engine.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type DroppedFieldsEvent
1818
} from '@objectstack/spec/data';
1919
import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
20-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data';
20+
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken } from '@objectstack/spec/data';
2121
import {
2222
DATA_MIGRATION_FLAG_OBJECT,
2323
FILE_REFERENCES_MIGRATION_ID,
@@ -1379,13 +1379,20 @@ export class ObjectQL implements IObjectQLEngine {
13791379
object, field: f.name, error: result.error,
13801380
});
13811381
}
1382-
} else if (dv === 'current_user') {
1382+
} else if (isCurrentUserDefaultToken(dv)) {
13831383
// `current_user` token → the acting user's id at insert time. Declarative
13841384
// counterpart to writing a beforeInsert hook; mirrors the 'NOW()' string
13851385
// convention and is resolved app-side per request (driver-agnostic), so
13861386
// `Field.user({ defaultValue: 'current_user' })` auto-fills the actor.
13871387
// When there is no authenticated user (system/anonymous), leave it unset
13881388
// and let required-validation decide — never stamp a bogus owner.
1389+
//
1390+
// The token spelling comes from `@objectstack/spec/data`
1391+
// (`DEFAULT_VALUE_TOKENS`), the one place the family is declared, so a
1392+
// driver's DDL reads the SAME set when deciding which `defaultValue`s
1393+
// may become a physical column DEFAULT. When the two sides disagreed,
1394+
// SQL emitted `DEFAULT 'current_user'` and the DATABASE overrode the
1395+
// "leave it unset" decision below with a literal non-id (#4560).
13891396
if (execCtx?.userId != null) out[f.name] = String(execCtx.userId);
13901397
} else {
13911398
out[f.name] = dv;

packages/plugins/driver-sql/src/schema-drift.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
import { createHash } from 'node:crypto';
3232

33-
import { isGlobalUnique, isUniqueDeclared } from '@objectstack/spec/data';
33+
import { isAppResolvedDefaultToken, isGlobalUnique, isUniqueDeclared } from '@objectstack/spec/data';
3434
import type { SchemaDiffEntry } from '@objectstack/spec/shared';
3535

3636
export type SqlDialectName = 'sqlite' | 'postgres' | 'mysql' | 'unknown';
@@ -50,6 +50,19 @@ export type DriftOp =
5050
| { type: 'widen_varchar'; table: string; column: string; to: number; from?: number }
5151
| { type: 'narrow_varchar'; table: string; column: string; to: number; from?: number }
5252
| { type: 'drop_column'; table: string; column: string }
53+
/**
54+
* Strip a column DEFAULT metadata never asked for (#4560).
55+
*
56+
* Today's only source is a `defaultValue` runtime token that a pre-fix build
57+
* emitted as a literal (`DEFAULT 'current_user'`), so every insert that
58+
* omitted the field got the token's own spelling instead of the engine's
59+
* deliberate "leave it unset". Dropping it cannot fail and cannot lose data —
60+
* stored rows keep whatever they hold; only FUTURE omitted inserts change,
61+
* from a bogus literal to NULL. Rows already carrying the bogus value are NOT
62+
* rewritten: they stay visible to the dangling-reference audit (#4551), whose
63+
* standing rule is report, never rewrite.
64+
*/
65+
| { type: 'drop_column_default'; table: string; column: string }
5366
/**
5467
* Retire the legacy platform-wide UNIQUE index on a now-tenant-scoped field
5568
* and put the composite `(tenantField, field)` in its place (#3696). The two
@@ -196,6 +209,13 @@ export interface PhysicalColumn {
196209
type: string;
197210
nullable: boolean;
198211
maxLength?: number;
212+
/**
213+
* The column's raw DEFAULT as the dialect reports it (knex `columnInfo`), or
214+
* `null`/`undefined` when it has none. Dialect-decorated — SQLite and Postgres
215+
* quote a string literal and Postgres appends a `::type` cast — so compare it
216+
* through {@link physicalDefaultIsToken}, never with `===`.
217+
*/
218+
defaultValue?: unknown;
199219
}
200220

201221
/** Minimal shape of a metadata field definition. */
@@ -206,6 +226,35 @@ export interface FieldDef {
206226
maxLength?: number;
207227
/** ADR-0113: the explicit physical constraint — nullability drift reads THIS, not `required`. */
208228
storage?: { notNull?: boolean };
229+
/**
230+
* The declared default. Only consulted for the runtime-token dimension
231+
* (#4560): a token is an instruction, so it must never appear as a physical
232+
* column DEFAULT. Literal defaults are deliberately NOT diffed — a hand-edited
233+
* DEFAULT on a column is a DBA's business, and reporting every one of them
234+
* would drown the plan the same way undeclared indexes would.
235+
*/
236+
defaultValue?: unknown;
237+
}
238+
239+
/**
240+
* Does the physical column DEFAULT literally spell out `token`?
241+
*
242+
* Each dialect decorates the literal it reports differently — SQLite
243+
* `'current_user'`, Postgres `'current_user'::character varying`, MySQL a bare
244+
* `current_user` — so the raw string is stripped of one layer of quoting and of
245+
* a trailing cast before comparing. Deliberately EXACT after that: this is the
246+
* fingerprint of a DEFAULT the platform itself emitted from a token spelling,
247+
* and matching loosely would let it drop a default that merely resembles one.
248+
*/
249+
export function physicalDefaultIsToken(raw: unknown, token: string): boolean {
250+
if (typeof raw !== 'string') return false;
251+
let s = raw.trim();
252+
const cast = s.indexOf('::');
253+
if (cast > 0) s = s.slice(0, cast).trim();
254+
if (s.length >= 2 && ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"')))) {
255+
s = s.slice(1, -1);
256+
}
257+
return s === token;
209258
}
210259

211260
/**
@@ -305,6 +354,36 @@ export function diffManagedTable(args: {
305354
});
306355
}
307356

357+
// ── runtime-token column DEFAULT (#4560) ──────────
358+
// A `defaultValue` the APPLICATION layer owns (`current_user`) must leave
359+
// the column with no DEFAULT at all. A build that predated the token family
360+
// passed it through to `col.defaultTo(...)`, so the database now supplies
361+
// the token's own spelling — a literal `'current_user'` in a
362+
// `lookup('sys_user')` column — for exactly the writes the engine
363+
// deliberately left unset. Detected here rather than fixed inline so it
364+
// travels the same plan/apply road as every other divergence.
365+
if (isAppResolvedDefaultToken(field.defaultValue) && physicalDefaultIsToken(col.defaultValue, field.defaultValue)) {
366+
out.push({
367+
kind: 'default_mismatch',
368+
remoteName: table,
369+
table,
370+
column: fieldName,
371+
expected: '(no column default)',
372+
actual: `DEFAULT '${field.defaultValue}'`,
373+
severity: 'warning',
374+
// Pure removal: stored rows are untouched and the statement cannot
375+
// fail, so dev auto-reconcile is welcome to apply it unattended.
376+
category: 'safe',
377+
op: { type: 'drop_column_default', table, column: fieldName },
378+
message:
379+
`${table}.${fieldName}: the column carries DEFAULT '${field.defaultValue}', but ` +
380+
`'${field.defaultValue}' is a runtime token the engine resolves per write — the database ` +
381+
`has been stamping the literal token into every insert that omitted the field (#4560). ` +
382+
`Dropping the default is non-destructive: run "os migrate apply". Rows already holding ` +
383+
`'${field.defaultValue}' are NOT rewritten — the dangling-reference audit reports them.`,
384+
});
385+
}
386+
308387
// ── varchar length (only where the dialect enforces it) ──────────
309388
if (
310389
enforcesVarcharLength(dialect) &&

0 commit comments

Comments
 (0)