diff --git a/.changeset/ddl-runtime-token-default.md b/.changeset/ddl-runtime-token-default.md new file mode 100644 index 0000000000..62ed957799 --- /dev/null +++ b/.changeset/ddl-runtime-token-default.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": patch +"@objectstack/objectql": patch +"@objectstack/driver-sql": patch +--- + +fix(driver-sql,spec,objectql): a `defaultValue` runtime token never becomes a column DEFAULT (#4560) + +`Field.user({ defaultValue: 'current_user' })` is resolved by the **engine**, at +insert time, from the request's `ExecutionContext` — and with no authenticated +user (system / anonymous writes: seed replay, package install, boot +provisioning) `applyFieldDefaults` deliberately leaves the field **unset** +rather than stamp a bogus owner. + +The SQL DDL had never heard of the token. `createColumn` passed any non-object +`defaultValue` straight through to `col.defaultTo(dv)`, so the column was +created as `DEFAULT 'current_user'` and the **database** overrode the engine's +decision: every insert that omitted the field stored the literal string +`current_user` in a `lookup('sys_user')` column — a value that is not any user's +id. `?expand` resolves it to nothing, and on an owner / approver field it is a +silent mis-attribution. Found by #4551's dangling-reference audit on its first +run against a real boot; #4441's referential check could never have caught it, +because it inspects the values a **caller** supplied and here nobody supplied +one. + +**The token vocabulary is now declared once, in `@objectstack/spec/data`** +(`DEFAULT_VALUE_TOKENS`, `isRuntimeDefaultToken`, `isNowDefaultToken`, +`isCurrentUserDefaultToken`, `isAppResolvedDefaultToken`). The engine's +insert-time resolution and the driver's DDL read the same set, which is the +actual defect: `'NOW()'` was special-cased in the branch immediately above for +precisely this reason, and `current_user` — the same convention family — simply +had no entry anywhere the DDL could see. A token added to the set tomorrow is +excluded from literal column DEFAULTs automatically, rather than leaking its own +spelling into the database the way this one did. + +**DDL, in one place** (`applyDeclaredColumnDefault`, shared by column creation +and the SQLite table rebuild): + +- `'NOW()'` → the driver-native canonical default, exactly as before; +- any other runtime token → **no column default at all** (the engine owns it); +- Expression envelopes (`{ dialect, source }`) → unchanged, no default; +- a real literal → emitted verbatim, unchanged. + +**Existing databases carry the wrong DEFAULT**, so it is corrected through the +managed schema-drift path (#2186) rather than a bespoke migration: a new +`default_mismatch` finding with a `drop_column_default` op, categorised `safe` +(the statement cannot fail and touches no rows). Dev boots with +`autoMigrate: 'safe'` reconcile it automatically; everywhere else it is reported +with an actionable hint and applied by `os migrate apply`. Postgres/MySQL use +`ALTER COLUMN … DROP DEFAULT`; SQLite, which cannot alter a default in place, +goes through the existing table rebuild — which now re-materialises every +column's default from **metadata**, so a sibling `defaultValue: 'NOW()'` column +keeps the default it always had instead of losing it to the rebuild. + +**Rows already holding the bogus value are NOT rewritten.** That is #4551's +standing rule — report, never rewrite — so they stay visible to the +dangling-reference audit for operators to resolve deliberately. diff --git a/packages/objectql/src/engine-default-value-tokens.test.ts b/packages/objectql/src/engine-default-value-tokens.test.ts new file mode 100644 index 0000000000..126e206b75 --- /dev/null +++ b/packages/objectql/src/engine-default-value-tokens.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The engine half of the `defaultValue` runtime-token contract (#4560). + * + * `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. + * + * 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 + * 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'; + +function makeMemoryDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string) { return Array.from(storeFor(object).values()); }, + findStream() { throw new Error('not implemented'); }, + async findOne(object: string) { return storeFor(object).values().next().value ?? null; }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update() { return null; }, + async upsert(object: string, data: Record) { return this.create(object, data); }, + async delete() { return true; }, + async count(object: string) { return storeFor(object).size; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany() { return 0; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +const owned = { + name: 'tok_doc', + label: 'Doc', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' as const }, + owner: { + name: 'owner', label: 'Owner', type: 'user' as const, + reference: 'sys_user', defaultValue: DEFAULT_VALUE_TOKEN_CURRENT_USER, + }, + }, +}; + +describe('[#4560] the `current_user` defaultValue token is engine-owned', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = new ObjectQL(); + engine.registerDriver(makeMemoryDriver().driver, true); + await engine.init(); + engine.registry.registerObject(owned as any); + }); + + it('stamps the acting user id on an authenticated insert', async () => { + const row: any = await engine.insert('tok_doc', { title: 'A' }, { context: { userId: 'usr_7' } } as any); + expect(row.owner).toBe('usr_7'); + }); + + it('leaves the field UNSET on a system/anonymous insert — never the literal token', async () => { + // The seed-replay / package-install / boot-provisioning shape. This is the + // decision a column DEFAULT used to override, writing the literal string + // `current_user` into a lookup('sys_user') column (#4560). + const row: any = await engine.insert('tok_doc', { title: 'B' }, { context: { isSystem: true } } as any); + expect(row.owner).toBeUndefined(); + expect(row.owner).not.toBe('current_user'); + }); + + it('an explicit null is treated as "not supplied" and still resolves the token (#2706)', async () => { + const row: any = await engine.insert('tok_doc', { title: 'C', owner: null }, { context: { userId: 'usr_9' } } as any); + expect(row.owner).toBe('usr_9'); + }); + + it('a NEAR-MISS spelling is a literal, not a token — it is an authoring error, not an alias', async () => { + engine.registry.registerObject({ + ...owned, + name: 'tok_typo', + fields: { ...owned.fields, owner: { ...owned.fields.owner, defaultValue: 'CURRENT_USER' } }, + } as any); + const row: any = await engine.insert('tok_typo', { title: 'D' }, { context: { userId: 'usr_1' } } as any); + // Deliberately NOT resolved: widening the match would make a genuinely + // intended literal unstorable. Lint catches the typo at authoring time. + expect(row.owner).toBe('CURRENT_USER'); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 69b144b14e..60cb801716 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 } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken } from '@objectstack/spec/data'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, @@ -1379,13 +1379,20 @@ export class ObjectQL implements IObjectQLEngine { object, field: f.name, error: result.error, }); } - } else if (dv === 'current_user') { + } else if (isCurrentUserDefaultToken(dv)) { // `current_user` token → the acting user's id at insert time. Declarative // counterpart to writing a beforeInsert hook; mirrors the 'NOW()' string // convention and is resolved app-side per request (driver-agnostic), so // `Field.user({ defaultValue: 'current_user' })` auto-fills the actor. // When there is no authenticated user (system/anonymous), leave it unset // and let required-validation decide — never stamp a bogus owner. + // + // The token spelling comes from `@objectstack/spec/data` + // (`DEFAULT_VALUE_TOKENS`), the one place the family is declared, so a + // driver's DDL reads the SAME set when deciding which `defaultValue`s + // may become a physical column DEFAULT. When the two sides disagreed, + // 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 { out[f.name] = dv; diff --git a/packages/plugins/driver-sql/src/schema-drift.ts b/packages/plugins/driver-sql/src/schema-drift.ts index 089692d33b..e6c2352a28 100644 --- a/packages/plugins/driver-sql/src/schema-drift.ts +++ b/packages/plugins/driver-sql/src/schema-drift.ts @@ -30,7 +30,7 @@ import { createHash } from 'node:crypto'; -import { isGlobalUnique, isUniqueDeclared } from '@objectstack/spec/data'; +import { isAppResolvedDefaultToken, isGlobalUnique, isUniqueDeclared } from '@objectstack/spec/data'; import type { SchemaDiffEntry } from '@objectstack/spec/shared'; export type SqlDialectName = 'sqlite' | 'postgres' | 'mysql' | 'unknown'; @@ -50,6 +50,19 @@ export type DriftOp = | { type: 'widen_varchar'; table: string; column: string; to: number; from?: number } | { type: 'narrow_varchar'; table: string; column: string; to: number; from?: number } | { type: 'drop_column'; table: string; column: string } + /** + * Strip a column DEFAULT metadata never asked for (#4560). + * + * Today's only source is a `defaultValue` runtime token that a pre-fix build + * emitted as a literal (`DEFAULT 'current_user'`), so every insert that + * omitted the field got the token's own spelling instead of the engine's + * deliberate "leave it unset". Dropping it cannot fail and cannot lose data — + * stored rows keep whatever they hold; only FUTURE omitted inserts change, + * from a bogus literal to NULL. Rows already carrying the bogus value are NOT + * rewritten: they stay visible to the dangling-reference audit (#4551), whose + * standing rule is report, never rewrite. + */ + | { type: 'drop_column_default'; table: string; column: string } /** * Retire the legacy platform-wide UNIQUE index on a now-tenant-scoped field * and put the composite `(tenantField, field)` in its place (#3696). The two @@ -196,6 +209,13 @@ export interface PhysicalColumn { type: string; nullable: boolean; maxLength?: number; + /** + * The column's raw DEFAULT as the dialect reports it (knex `columnInfo`), or + * `null`/`undefined` when it has none. Dialect-decorated — SQLite and Postgres + * quote a string literal and Postgres appends a `::type` cast — so compare it + * through {@link physicalDefaultIsToken}, never with `===`. + */ + defaultValue?: unknown; } /** Minimal shape of a metadata field definition. */ @@ -206,6 +226,35 @@ export interface FieldDef { maxLength?: number; /** ADR-0113: the explicit physical constraint — nullability drift reads THIS, not `required`. */ storage?: { notNull?: boolean }; + /** + * The declared default. Only consulted for the runtime-token dimension + * (#4560): a token is an instruction, so it must never appear as a physical + * column DEFAULT. Literal defaults are deliberately NOT diffed — a hand-edited + * DEFAULT on a column is a DBA's business, and reporting every one of them + * would drown the plan the same way undeclared indexes would. + */ + defaultValue?: unknown; +} + +/** + * Does the physical column DEFAULT literally spell out `token`? + * + * Each dialect decorates the literal it reports differently — SQLite + * `'current_user'`, Postgres `'current_user'::character varying`, MySQL a bare + * `current_user` — so the raw string is stripped of one layer of quoting and of + * a trailing cast before comparing. Deliberately EXACT after that: this is the + * fingerprint of a DEFAULT the platform itself emitted from a token spelling, + * and matching loosely would let it drop a default that merely resembles one. + */ +export function physicalDefaultIsToken(raw: unknown, token: string): boolean { + if (typeof raw !== 'string') return false; + let s = raw.trim(); + const cast = s.indexOf('::'); + if (cast > 0) s = s.slice(0, cast).trim(); + if (s.length >= 2 && ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"')))) { + s = s.slice(1, -1); + } + return s === token; } /** @@ -305,6 +354,36 @@ export function diffManagedTable(args: { }); } + // ── runtime-token column DEFAULT (#4560) ────────── + // A `defaultValue` the APPLICATION layer owns (`current_user`) must leave + // the column with no DEFAULT at all. A build that predated the token family + // passed it through to `col.defaultTo(...)`, so the database now supplies + // the token's own spelling — a literal `'current_user'` in a + // `lookup('sys_user')` column — for exactly the writes the engine + // deliberately left unset. Detected here rather than fixed inline so it + // travels the same plan/apply road as every other divergence. + if (isAppResolvedDefaultToken(field.defaultValue) && physicalDefaultIsToken(col.defaultValue, field.defaultValue)) { + out.push({ + kind: 'default_mismatch', + remoteName: table, + table, + column: fieldName, + expected: '(no column default)', + actual: `DEFAULT '${field.defaultValue}'`, + severity: 'warning', + // Pure removal: stored rows are untouched and the statement cannot + // fail, so dev auto-reconcile is welcome to apply it unattended. + category: 'safe', + op: { type: 'drop_column_default', table, column: fieldName }, + message: + `${table}.${fieldName}: the column carries DEFAULT '${field.defaultValue}', but ` + + `'${field.defaultValue}' is a runtime token the engine resolves per write — the database ` + + `has been stamping the literal token into every insert that omitted the field (#4560). ` + + `Dropping the default is non-destructive: run "os migrate apply". Rows already holding ` + + `'${field.defaultValue}' are NOT rewritten — the dangling-reference audit reports them.`, + }); + } + // ── varchar length (only where the dialect enforces it) ────────── if ( enforcesVarcharLength(dialect) && diff --git a/packages/plugins/driver-sql/src/sql-driver-runtime-token-default.test.ts b/packages/plugins/driver-sql/src/sql-driver-runtime-token-default.test.ts new file mode 100644 index 0000000000..00f296d698 --- /dev/null +++ b/packages/plugins/driver-sql/src/sql-driver-runtime-token-default.test.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defaultValue` runtime tokens must never become a literal column DEFAULT + * (#4560). + * + * `Field.user({ defaultValue: 'current_user' })` is resolved by the ENGINE, at + * insert time, from the request's `ExecutionContext` — and when there is no + * authenticated user (system / anonymous writes: seed replay, package install, + * boot provisioning) the engine deliberately leaves the field UNSET rather than + * stamp a bogus owner. + * + * The DDL used to pass any non-object `defaultValue` straight through to + * `col.defaultTo(dv)`, so SQL emitted `DEFAULT 'current_user'` and the DATABASE + * overrode that decision: every omitted insert landed the literal string + * `current_user` in a `lookup('sys_user')` column — a value that is not any + * user's id, discovered by #4551's dangling-reference audit on its first real + * run. + * + * `'NOW()'` sat in the adjacent branch, translated to a driver-native default + * for exactly this reason. These tests pin the whole family: `NOW()` keeps its + * native default, every other token emits NONE, and a database created before + * the fix has its wrong DEFAULT dropped by the schema-evolution path — WITHOUT + * rewriting the rows that already hold the bogus value (#4551's standing rule: + * report, never rewrite). + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { SqlDriver } from '../src/index.js'; +import { + DEFAULT_VALUE_TOKENS, + DEFAULT_VALUE_TOKEN_CURRENT_USER, + isNowDefaultToken, +} from '@objectstack/spec/data'; + +const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe('SqlDriver — defaultValue runtime tokens never become a column DEFAULT (#4560)', () => { + let knexInstance: any; + + const makeDriver = (opts: any = {}) => { + const d = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + ...opts, + }); + knexInstance = (d as any).knex; + (d as any).logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; + return d; + }; + + /** The raw `CREATE TABLE` SQLite stored — the only unambiguous view of a DEFAULT. */ + const tableSql = async (table: string): Promise => { + const row = await knexInstance.raw( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", + [table], + ); + return String(row?.[0]?.sql ?? row?.sql ?? ''); + }; + + afterEach(async () => { + await knexInstance?.destroy(); + }); + + const fieldZoo = [ + { + name: 'field_zoo', + fields: { + title: { type: 'string' }, + // The #4560 repro, verbatim from examples/app-showcase. + f_owner: { type: 'user', reference: 'sys_user', defaultValue: DEFAULT_VALUE_TOKEN_CURRENT_USER }, + // The token that DOES have a database counterpart — its behaviour must + // not move, or the fix has traded one silent default for another. + f_seen_at: { type: 'datetime', defaultValue: 'NOW()' }, + // An ordinary literal default — still emitted, unchanged. + f_status: { type: 'string', defaultValue: 'open' }, + }, + }, + ]; + + // ── (a) the column is created with NO database default ────────────────────── + + it('creates a `current_user`-defaulted column with NO database default', async () => { + const driver = makeDriver(); + await driver.initObjects(fieldZoo as any); + + const info = await knexInstance('field_zoo').columnInfo(); + expect(info.f_owner.defaultValue ?? null).toBeNull(); + + const sql = await tableSql('field_zoo'); + // The token's own spelling must appear nowhere in the DDL. + expect(sql).not.toContain('current_user'); + }); + + it('still emits an ordinary literal default (the fix excludes tokens, not defaults)', async () => { + const driver = makeDriver(); + await driver.initObjects(fieldZoo as any); + const info = await knexInstance('field_zoo').columnInfo(); + expect(String(info.f_status.defaultValue)).toContain('open'); + }); + + // ── (b) an insert with no user context leaves the column NULL ─────────────── + + it('an insert that omits the field leaves it NULL — not the literal token', async () => { + const driver = makeDriver(); + await driver.initObjects(fieldZoo as any); + + // A system/anonymous write: exactly the seed-replay shape that produced the + // two dangling `sys_user:current_user` references #4551's audit reported. + await driver.create('field_zoo', { id: 'z1', title: 'specimen' }, { bypassTenantAudit: true } as any); + + const row = await knexInstance('field_zoo').where('id', 'z1').first(); + expect(row.f_owner).toBeNull(); + expect(row.f_owner).not.toBe('current_user'); + }); + + it('an explicitly supplied user id is still stored (the column is a normal lookup)', async () => { + const driver = makeDriver(); + await driver.initObjects(fieldZoo as any); + await driver.create('field_zoo', { id: 'z2', title: 't', f_owner: 'usr_42' }, { bypassTenantAudit: true } as any); + const row = await knexInstance('field_zoo').where('id', 'z2').first(); + expect(row.f_owner).toBe('usr_42'); + }); + + // ── (c) 'NOW()' behaviour is unchanged ───────────────────────────────────── + + it("REGRESSION: 'NOW()' still gets its driver-native default and stores a canonical instant", async () => { + const driver = makeDriver(); + await driver.initObjects(fieldZoo as any); + await driver.create('field_zoo', { id: 'z3', title: 't' }, { bypassTenantAudit: true } as any); + const row = await knexInstance('field_zoo').where('id', 'z3').first(); + expect(row.f_seen_at).toMatch(ISO_Z); + expect(String(row.f_seen_at)).not.toContain('NOW()'); + }); + + // ── (e) revert-proof: the DDL consults the SPEC's token set, not a name ───── + + it('REVERT-PROOF: no token in DEFAULT_VALUE_TOKENS reaches the DDL as a literal', async () => { + // Looping the spec's own list is what makes this survive the next token: + // an open-coded `dv === 'current_user'` in the DDL passes today and fails + // the moment DEFAULT_VALUE_TOKENS grows. Dropping the exclusion branch + // altogether fails it immediately — the literal reappears in the DDL. + const driver = makeDriver(); + const fields: Record = { title: { type: 'string' } }; + DEFAULT_VALUE_TOKENS.forEach((token, i) => { + fields[`tok_${i}`] = { type: 'string', defaultValue: token }; + }); + await driver.initObjects([{ name: 'token_zoo', fields }] as any); + + const info = await knexInstance('token_zoo').columnInfo(); + DEFAULT_VALUE_TOKENS.forEach((token, i) => { + const physical = info[`tok_${i}`].defaultValue; + if (isNowDefaultToken(token)) { + // Translated, never literal. + expect(physical).not.toBeNull(); + expect(String(physical)).not.toContain('NOW()'); + } else { + // Owned by the application layer — no column default at all. + expect(physical ?? null).toBeNull(); + } + }); + }); + + // ── (d) the schema-evolution path corrects a pre-existing wrong DEFAULT ───── + + /** + * A database created by a pre-fix build: `f_owner` carries the literal token + * as its DEFAULT and two rows already ate it. `f_seen_at` carries a legitimate + * `NOW()`-family default that must survive the repair untouched. + */ + const seedPreFixTable = async () => { + await knexInstance.raw(` + CREATE TABLE field_zoo ( + id varchar(255) not null primary key, + created_at datetime, + updated_at datetime, + title varchar(255), + f_owner varchar(255) default 'current_user', + f_seen_at datetime default (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + f_status varchar(255) default 'open' + ) + `); + await knexInstance('field_zoo').insert([ + { id: 'IFMh0g', title: 'A', f_owner: 'current_user' }, + { id: 'TP2KMq', title: 'B', f_owner: 'current_user' }, + ]); + }; + + it('detectManagedDrift reports the stale token DEFAULT as safe, reconcilable drift', async () => { + const driver = makeDriver(); + await seedPreFixTable(); + await driver.initObjects(fieldZoo as any); + + const drift = await driver.detectManagedDrift(fieldZoo as any); + const entry = drift.find((d) => d.column === 'f_owner'); + expect(entry).toBeDefined(); + expect(entry!.kind).toBe('default_mismatch'); + expect(entry!.category).toBe('safe'); + expect(entry!.op.type).toBe('drop_column_default'); + expect(entry!.message).toContain('current_user'); + + // Only the token column drifts — a correct `NOW()` default is not drift. + expect(drift.some((d) => d.column === 'f_seen_at')).toBe(false); + expect(drift.some((d) => d.column === 'f_status')).toBe(false); + }); + + it('a boot with autoMigrate=safe drops the wrong DEFAULT, so the NEXT omitted insert is NULL', async () => { + const driver = makeDriver({ autoMigrate: 'safe', schemaMode: 'managed' }); + await seedPreFixTable(); + + // This is the boot: `initObjects` detects the drift and auto-reconciles the + // `safe` subset, exactly as it does for a relaxed NOT NULL. + await driver.initObjects(fieldZoo as any); + + const info = await knexInstance('field_zoo').columnInfo(); + expect(info.f_owner.defaultValue ?? null).toBeNull(); + expect(await driver.detectManagedDrift(fieldZoo as any)).toEqual([]); + + await driver.create('field_zoo', { id: 'after', title: 'C' }, { bypassTenantAudit: true } as any); + const row = await knexInstance('field_zoo').where('id', 'after').first(); + expect(row.f_owner).toBeNull(); + }); + + it('the repair does NOT rewrite rows that already hold the bogus value (#4551: report, never rewrite)', async () => { + const driver = makeDriver({ autoMigrate: 'safe', schemaMode: 'managed' }); + await seedPreFixTable(); + await driver.initObjects(fieldZoo as any); + + const rows = await knexInstance('field_zoo').whereIn('id', ['IFMh0g', 'TP2KMq']).orderBy('id'); + expect(rows.map((r: any) => r.f_owner)).toEqual(['current_user', 'current_user']); + // …and nothing else about them moved either. + expect(rows.map((r: any) => r.title)).toEqual(['A', 'B']); + }); + + it('the SQLite rebuild keeps every OTHER declared default (a NOW() sibling is not collateral)', async () => { + const driver = makeDriver({ autoMigrate: 'safe', schemaMode: 'managed' }); + await seedPreFixTable(); + await driver.initObjects(fieldZoo as any); + + // The rebuild re-materializes defaults from METADATA, so the sibling + // columns come back with exactly the defaults `createColumn` would emit. + const info = await knexInstance('field_zoo').columnInfo(); + expect(String(info.f_seen_at.defaultValue ?? '')).toContain('strftime'); + expect(String(info.f_status.defaultValue ?? '')).toContain('open'); + + await driver.create('field_zoo', { id: 'post', title: 'D' }, { bypassTenantAudit: true } as any); + const row = await knexInstance('field_zoo').where('id', 'post').first(); + expect(row.f_seen_at).toMatch(ISO_Z); + expect(row.f_status).toBe('open'); + }); + + it('a column whose DEFAULT is a real literal is never mistaken for a token default', async () => { + // Guard against an over-eager repair: only the token's own spelling is the + // fingerprint of a default the platform itself emitted from a token. + const driver = makeDriver(); + await knexInstance.raw(` + CREATE TABLE picky ( + id varchar(255) not null primary key, + created_at datetime, + updated_at datetime, + owner varchar(255) default 'usr_house_account' + ) + `); + const meta = [ + { name: 'picky', fields: { owner: { type: 'user', reference: 'sys_user', defaultValue: DEFAULT_VALUE_TOKEN_CURRENT_USER } } }, + ]; + await driver.initObjects(meta as any); + expect(await driver.detectManagedDrift(meta as any)).toEqual([]); + }); +}); diff --git a/packages/plugins/driver-sql/src/sql-driver.ts b/packages/plugins/driver-sql/src/sql-driver.ts index 1e52625c4c..75a8f5a2ab 100644 --- a/packages/plugins/driver-sql/src/sql-driver.ts +++ b/packages/plugins/driver-sql/src/sql-driver.ts @@ -11,6 +11,11 @@ import type { QueryAST, DriverOptions, SchemaMode } from '@objectstack/spec/data import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, isGlobalUnique, isUniqueDeclared, type AutonumberToken } from '@objectstack/spec/data'; import { STRUCTURED_JSON_TYPES, FILE_REFERENCE_TYPES, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data'; import { canonicalAstOperator } from '@objectstack/spec/data'; +// `defaultValue` runtime tokens (#4560). The DDL below asks the SPEC — not a +// list of its own — which `defaultValue`s are instructions rather than literals, +// so the engine and this driver can never disagree about what may become a +// physical column DEFAULT. +import { isNowDefaultToken, isRuntimeDefaultToken } from '@objectstack/spec/data'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { StandardErrorCode } from '@objectstack/spec/api'; import { StorageNameMapping } from '@objectstack/spec/system'; @@ -145,11 +150,13 @@ function repairNaiveUtcAuditTimestamp(value: unknown): unknown { /** * Whether a field's `defaultValue` is the framework's `'NOW()'` convention * ("use the database clock at insert time"). Case-insensitive, whitespace - * tolerant. Single source for the two places `createColumn` checks it. + * tolerant. + * + * Thin alias over the spec's {@link isNowDefaultToken} — the token vocabulary + * itself lives in `@objectstack/spec/data` so the engine's insert-time + * resolution and this driver's DDL read one set (#4560). */ -function isNowDefaultValue(v: unknown): v is string { - return typeof v === 'string' && /^now\(\)$/i.test(v.trim()); -} +const isNowDefaultValue = isNowDefaultToken; /** * Read-side normalization for user-declared `Field.datetime` columns on SQLite. @@ -3665,6 +3672,10 @@ export class SqlDriver implements IDataDriver { type: c.type, nullable: c.nullable, maxLength: c.maxLength, + // The raw, dialect-decorated DEFAULT — the only evidence that a column was + // created by a build which turned a `defaultValue` runtime token into a + // literal (#4560). + defaultValue: c.defaultValue, })); const out = diffManagedTable({ table: tableName, fields, columns: physical, dialect: this.dialectName }); out.push(...(await this.detectTableIndexDrift(tableName, fields, declaredIndexes, new Set(cols.map((c) => c.name))))); @@ -3922,6 +3933,9 @@ export class SqlDriver implements IDataDriver { case 'drop_column': await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [table, column]); return true; + case 'drop_column_default': + await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? DROP DEFAULT', [table, column]); + return true; } } if (this.isMysql) { @@ -3949,6 +3963,12 @@ export class SqlDriver implements IDataDriver { case 'drop_column': await this.knex.raw('ALTER TABLE ?? DROP COLUMN ??', [table, column]); return true; + case 'drop_column_default': + // `ALTER … ALTER COLUMN … DROP DEFAULT` — the one ALTER COLUMN form + // MySQL accepts without restating the type, so it cannot lose a + // varchar length the way MODIFY can. + await this.knex.raw('ALTER TABLE ?? ALTER COLUMN ?? DROP DEFAULT', [table, column]); + return true; } } this.logger.warn(`[schema-drift] ${op.type} on ${table}.${column} is unsupported on dialect '${this.dialectName}' — skipped`); @@ -3957,9 +3977,19 @@ export class SqlDriver implements IDataDriver { /** * Rebuild a SQLite table applying a set of column edits (relax/tighten NOT - * NULL, drop column), preserving all other columns and their data. Follows - * the official SQLite procedure: create patched table → copy → drop → rename. - * varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored. + * NULL, drop column, drop a column DEFAULT), preserving all other columns and + * their data. Follows the official SQLite procedure: create patched table → + * copy → drop → rename. varchar widen/narrow are no-ops on SQLite (dynamic + * typing) and ignored. + * + * SQLite cannot alter a column's DEFAULT in place, so `drop_column_default` + * (#4560) is reconciled here too — by re-materializing every column's default + * from METADATA through {@link applyDeclaredColumnDefault} and simply not + * re-emitting the dropped one. Rebuilding from metadata rather than copying + * the physical DEFAULT is what makes this safe both ways: the token default + * the op targets is gone because metadata never declared it, and a sibling + * `defaultValue: 'NOW()'` column keeps the default it always had instead of + * silently losing it to the rebuild. * * Unique field-level constraints and declared indexes are recreated from * metadata afterwards (the source of truth). DB-level foreign keys declared @@ -3970,10 +4000,12 @@ export class SqlDriver implements IDataDriver { const relax = new Set(); const tighten = new Set(); const drop = new Set(); + const dropDefault = new Set(); for (const e of ents) { if (e.op.type === 'relax_not_null') relax.add(e.op.column); else if (e.op.type === 'tighten_not_null') tighten.add(e.op.column); else if (e.op.type === 'drop_column') drop.add(e.op.column); + else if (e.op.type === 'drop_column_default') dropDefault.add(e.op.column); // widen/narrow varchar: SQLite ignores declared length — nothing to do. } @@ -3996,7 +4028,17 @@ export class SqlDriver implements IDataDriver { if (!col) continue; const nullable = relax.has(c.name) ? true : tighten.has(c.name) ? false : c.nullable; if (!nullable && c.name !== 'id') col.notNullable(); - if (c.name === 'created_at' || c.name === 'updated_at') col.defaultTo(this.knex.fn.now()); + if (c.name === 'created_at' || c.name === 'updated_at') { + col.defaultTo(this.knex.fn.now()); + } else if (!dropDefault.has(c.name)) { + // Re-emit the METADATA-declared default. The rebuild dropped the + // original table, so a column whose default is not restated here + // comes back without one — which is precisely what a + // `drop_column_default` op wants, and precisely what a + // `defaultValue: 'NOW()'` sibling must not suffer. + const f = (fields as Record)[c.name]; + if (f) this.applyDeclaredColumnDefault(col, f, f.type || 'string'); + } } }); const colList = keptNames.map((n) => `"${n}"`).join(', '); @@ -5938,26 +5980,51 @@ export class SqlDriver implements IDataDriver { // `storage.notNull` explicitly via the `field-required-notnull-explicit` // conversion, so their columns come out exactly as they always did. if ((field as { storage?: { notNull?: boolean } }).storage?.notNull) col.notNullable(); - // `defaultValue: 'NOW()'` is a framework convention for "use the - // database clock at insert time". Translate it to the driver-native - // canonical default (`nowColumnDefault`) so the column gets a real, - // zone-explicit default instead of leaving the literal string 'NOW()' - // for whatever upstream code happens to write — and, on SQLite, instead - // of the timezone-naive `CURRENT_TIMESTAMP` that `knex.fn.now()` emits. - if ( - (type === 'datetime' || type === 'date' || type === 'time') && - isNowDefaultValue(field.defaultValue) - ) { - col.defaultTo(this.nowColumnDefault(type)); - } else if (field.defaultValue !== undefined && field.defaultValue !== null) { - const dv = field.defaultValue; - if (isNowDefaultValue(dv)) { - col.defaultTo(this.nowColumnDefault(type)); - } else if (typeof dv !== 'object') { - col.defaultTo(dv as any); - } - } + this.applyDeclaredColumnDefault(col, field, type); + } + } + + /** + * Emit the physical column DEFAULT a field's `defaultValue` calls for — or, + * deliberately, none at all. + * + * The single place `defaultValue` becomes DDL. `createColumn` uses it for a + * fresh column and {@link rebuildSqliteTablePatched} for a re-materialized + * one, so a SQLite table rebuild cannot quietly hand back a column whose + * default differs from the one metadata declares. + * + * Four cases, in order: + * + * 1. **`'NOW()'`** — the one runtime token with a database counterpart. + * Translated to the driver-native canonical default + * ({@link nowColumnDefault}) so the column gets a real, zone-explicit + * default instead of the literal string `'NOW()'` for whatever upstream + * code happens to write — and, on SQLite, instead of the timezone-naive + * `CURRENT_TIMESTAMP` that `knex.fn.now()` emits. + * 2. **Any other runtime token** (`current_user`, and anything the spec adds + * to `DEFAULT_VALUE_TOKENS` later) — resolved by the ENGINE at insert time + * against the request context, with **no** database counterpart, so this + * emits NOTHING. That omission is the contract, not an oversight: the + * engine deliberately leaves a `current_user` field UNSET when there is no + * authenticated user (system/anonymous writes), and a column DEFAULT + * silently overrode that decision — writing the literal string + * `'current_user'` into `lookup('sys_user')` columns (#4560). Checking the + * spec's predicate rather than an open-coded name keeps a future token + * from leaking its own spelling the same way. + * 3. **Objects** — Expression envelopes (`{ dialect, source }`), evaluated + * app-side; never a column DEFAULT. + * 4. **Everything else** — a real literal, emitted verbatim. + */ + protected applyDeclaredColumnDefault(col: Knex.ColumnBuilder, field: any, type: string): void { + const dv = field?.defaultValue; + if (dv === undefined || dv === null) return; + if (isNowDefaultValue(dv)) { + col.defaultTo(this.nowColumnDefault(type)); + return; } + if (isRuntimeDefaultToken(dv)) return; + if (typeof dv === 'object') return; + col.defaultTo(dv as any); } // ── Database helpers ──────────────────────────────────────────────────────── diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index a6718b2a93..529f2cd200 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -252,6 +252,10 @@ "DATE_MACRO_TOKENS (const)", "DATE_MACRO_UNITS (const)", "DATE_MACRO_WRAPPED_RE (const)", + "DEFAULT_VALUE_TOKENS (const)", + "DEFAULT_VALUE_TOKEN_CURRENT_USER (const)", + "DEFAULT_VALUE_TOKEN_DESCRIPTIONS (const)", + "DEFAULT_VALUE_TOKEN_NOW (const)", "DRIVER_CONFIG_SCHEMAS (const)", "DRIVER_ID_ALIASES (const)", "DataEngineAggregateOptions (type)", @@ -298,6 +302,7 @@ "DateMacroToken (type)", "DateMacroTokenSchema (const)", "DateMacroUnit (type)", + "DefaultValueToken (type)", "Dimension (type)", "DimensionSchema (const)", "DimensionType (const)", @@ -673,8 +678,10 @@ "hookForm (const)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", + "isAppResolvedDefaultToken (function)", "isCompatible (function)", "isContextToken (function)", + "isCurrentUserDefaultToken (function)", "isDateMacroToken (function)", "isFileIdToken (function)", "isFilterAST (function)", @@ -683,7 +690,9 @@ "isKnownFilterToken (function)", "isLegacyApiMethod (function)", "isMultiValueField (function)", + "isNowDefaultToken (function)", "isPlainRecord (function)", + "isRuntimeDefaultToken (function)", "isTenancyDisabled (function)", "isTitleEligible (function)", "isUniqueDeclared (function)", diff --git a/packages/spec/src/data/default-value-tokens.test.ts b/packages/spec/src/data/default-value-tokens.test.ts new file mode 100644 index 0000000000..9cb127bcd4 --- /dev/null +++ b/packages/spec/src/data/default-value-tokens.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_VALUE_TOKENS, + DEFAULT_VALUE_TOKEN_NOW, + DEFAULT_VALUE_TOKEN_CURRENT_USER, + DEFAULT_VALUE_TOKEN_DESCRIPTIONS, + isNowDefaultToken, + isCurrentUserDefaultToken, + isRuntimeDefaultToken, + isAppResolvedDefaultToken, +} from './default-value-tokens.js'; + +describe('defaultValue runtime tokens (#4560)', () => { + it('declares the complete family, and every member is described', () => { + expect([...DEFAULT_VALUE_TOKENS]).toEqual(['NOW()', 'current_user']); + for (const token of DEFAULT_VALUE_TOKENS) { + expect(DEFAULT_VALUE_TOKEN_DESCRIPTIONS[token]).toBeTruthy(); + } + }); + + it("recognises 'NOW()' case-insensitively and whitespace-tolerantly (the driver's long-standing rule)", () => { + for (const v of ['NOW()', 'now()', 'Now()', ' NOW() ']) { + expect(isNowDefaultToken(v)).toBe(true); + expect(isRuntimeDefaultToken(v)).toBe(true); + } + expect(isNowDefaultToken('NOW')).toBe(false); + expect(isNowDefaultToken('now(1)')).toBe(false); + }); + + it("matches 'current_user' EXACTLY, mirroring applyFieldDefaults", () => { + expect(isCurrentUserDefaultToken(DEFAULT_VALUE_TOKEN_CURRENT_USER)).toBe(true); + // Near-miss spellings are authoring errors, not tokens — widening the match + // here would make a genuinely-intended literal unstorable. + for (const v of ['CURRENT_USER', '{current_user}', 'current_user_id', 'currentUser', ' current_user ']) { + expect(isCurrentUserDefaultToken(v)).toBe(false); + } + }); + + it('classifies every token as runtime, and splits them by who resolves them', () => { + for (const token of DEFAULT_VALUE_TOKENS) expect(isRuntimeDefaultToken(token)).toBe(true); + // `NOW()` has a database counterpart, so it is NOT app-resolved: a driver + // translates it into a native column DEFAULT. + expect(isAppResolvedDefaultToken(DEFAULT_VALUE_TOKEN_NOW)).toBe(false); + // `current_user` has none — the column must carry no DEFAULT at all. + expect(isAppResolvedDefaultToken(DEFAULT_VALUE_TOKEN_CURRENT_USER)).toBe(true); + }); + + it('treats ordinary literals and non-strings as values, never as instructions', () => { + for (const v of ['open', '', 0, 1, false, true, null, undefined, {}, [], { dialect: 'cel', source: 'today()' }]) { + expect(isRuntimeDefaultToken(v)).toBe(false); + expect(isAppResolvedDefaultToken(v)).toBe(false); + } + }); +}); diff --git a/packages/spec/src/data/default-value-tokens.ts b/packages/spec/src/data/default-value-tokens.ts new file mode 100644 index 0000000000..3ccab3c8ea --- /dev/null +++ b/packages/spec/src/data/default-value-tokens.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defaultValue` runtime tokens — the reserved STRING sentinels a field's + * `defaultValue` may carry that are **not literals to store**, but instructions + * resolved at insert time by whoever owns the value. + * + * # Why this vocabulary has to live in one place + * + * `Field.defaultValue` is `z.unknown()`: an author may put a literal (`'open'`, + * `0`, `false`), an Expression envelope (`{ dialect: 'cel', source: 'today()' }`, + * ROADMAP §M9.9b), or one of these tokens in it. Two very different subsystems + * then have to agree on which is which: + * + * - the **engine** (`ObjectQL.applyFieldDefaults`), which fills an omitted + * field on the insert path, and + * - a **driver's DDL**, which turns `defaultValue` into a physical column + * DEFAULT. + * + * When only one of them knows a token, the other treats it as a literal — and a + * literal token is silently WRONG data. That is exactly how `current_user` + * became a column DEFAULT in SQL and how the string `'current_user'` ended up + * stored in `lookup('sys_user')` columns for every write the engine had + * deliberately left unset (#4560): the engine handled the token, the DDL had + * never heard of it, and `col.defaultTo(dv)` passed it straight through. + * + * So the token set is declared HERE, once, and both sides read it. A token + * added below is automatically excluded from literal column DEFAULTs by every + * driver that consults {@link isRuntimeDefaultToken}, whether or not that driver + * has learned to translate it. + * + * # Who resolves what + * + * | Token | Resolved by | Physical column DEFAULT | + * |---|---|---| + * | `NOW()` | the **database** (`CURRENT_TIMESTAMP` and friends) | yes — the driver's native now-expression | + * | `current_user` | the **engine**, from the request's `ExecutionContext` | **none** | + * + * `current_user` has no database counterpart at all: SQL's own `CURRENT_USER` + * is the *connection* role, not an ObjectStack `sys_user.id`, and the engine + * deliberately leaves the field UNSET when there is no authenticated user + * (system/anonymous writes) rather than stamp a bogus owner. A column DEFAULT + * would override precisely that decision, which is why the split above is a + * contract and not an implementation detail — see {@link isRuntimeDefaultToken}. + * + * # Out of scope + * + * - `{current_user_id}` / `{current_org_id}` — the braced **filter** vocabulary + * (`./context-tokens.zod.ts`). Different surface, different resolver; the + * near-miss overlap is catalogued there as `CONTEXT_TOKEN_SUGGESTIONS`. + * - `current_user.*` in RLS `using` expressions — an expression root, not a + * default (`@objectstack/plugin-security`). + * - Expression-envelope defaults (`{ dialect, source }`) — a structured value, + * recognised by shape rather than by spelling, and never a column DEFAULT. + */ + +/** + * "Use the clock at insert time." The one token with a native database + * counterpart, so a driver translates it into a real column DEFAULT. + */ +export const DEFAULT_VALUE_TOKEN_NOW = 'NOW()'; + +/** + * "Use the acting user's id at insert time." Resolved by the engine against the + * request context; NEVER a column DEFAULT (see the module note). + */ +export const DEFAULT_VALUE_TOKEN_CURRENT_USER = 'current_user'; + +/** + * The complete set of `defaultValue` runtime tokens. + * + * Deliberately tiny, for the same reason `CONTEXT_TOKENS` is: every entry is a + * spelling that stops being usable as a literal default anywhere in the + * platform, and it has to be honoured by the engine and by every driver's DDL. + */ +export const DEFAULT_VALUE_TOKENS = [ + DEFAULT_VALUE_TOKEN_NOW, + DEFAULT_VALUE_TOKEN_CURRENT_USER, +] as const; + +export type DefaultValueToken = (typeof DEFAULT_VALUE_TOKENS)[number]; + +/** + * Description table — feeds skill / docs generation. Pure data, deliberately + * not exported through any zod schema. + */ +export const DEFAULT_VALUE_TOKEN_DESCRIPTIONS: Record = { + 'NOW()': 'The database clock at insert time. Emitted as a native column DEFAULT.', + current_user: + "The acting user's id at insert time, from the request's ExecutionContext. " + + 'Never emitted as a column DEFAULT — with no authenticated user the field stays unset.', +}; + +/** + * Is `v` the `'NOW()'` token? + * + * Case-insensitive and whitespace tolerant, which is the rule the SQL driver + * has always applied to this token (`'now()'`, `' NOW() '` all count) and the + * one the ~100 `defaultValue: 'NOW()'` declarations in the platform objects + * rely on. + */ +export function isNowDefaultToken(v: unknown): v is string { + return typeof v === 'string' && /^now\(\)$/i.test(v.trim()); +} + +/** + * Is `v` the `'current_user'` token? + * + * EXACT match, matching `applyFieldDefaults`. Near-miss spellings + * (`{current_user}`, `currentUser`) are authoring errors caught by lint — they + * are deliberately NOT accepted here, because silently widening the token would + * make a genuinely-intended literal unstorable. + */ +export function isCurrentUserDefaultToken(v: unknown): v is string { + return v === DEFAULT_VALUE_TOKEN_CURRENT_USER; +} + +/** + * Is `v` **any** `defaultValue` runtime token? + * + * The predicate a DDL emitter wants: a token is an instruction, never a value, + * so it must never reach `col.defaultTo(...)` as a literal. A driver that can + * translate a particular token natively (as SQL does for `NOW()`) checks for + * that one FIRST and falls through to this predicate for the rest — which means + * a token added to {@link DEFAULT_VALUE_TOKENS} tomorrow degrades to "no column + * default" (the engine's job) instead of leaking its own spelling into the + * database. + */ +export function isRuntimeDefaultToken(v: unknown): v is string { + return isNowDefaultToken(v) || isCurrentUserDefaultToken(v); +} + +/** + * Is `v` a runtime token that the **application layer** owns end to end — i.e. + * one with no database counterpart, whose column must therefore carry NO + * DEFAULT at all? + * + * The complement of {@link isNowDefaultToken} within the token set. This is what + * a schema-evolution pass checks a pre-existing column's physical DEFAULT + * against: a column whose default is the literal token text was created by a + * build that did not know the token, and the default has to go (#4560). + */ +export function isAppResolvedDefaultToken(v: unknown): v is string { + return isRuntimeDefaultToken(v) && !isNowDefaultToken(v); +} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index d4b12dae6c..cb9756f179 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -18,6 +18,11 @@ export * from './calendar-day'; // the sibling vocabulary to date macros. Presentation scope only; RLS is the // enforcement boundary. See context-tokens.zod.ts. export * from './context-tokens.zod'; +// `defaultValue` runtime tokens (`NOW()`, `current_user`) — the reserved string +// sentinels that are instructions, not literals. Declared once so the engine's +// insert-time default resolution and every driver's DDL agree on which +// `defaultValue`s may become a physical column DEFAULT (#4560). +export * from './default-value-tokens'; export * from './object.zod'; // API-method derivation — the single source of truth turning an object's // `enable.apiMethods` whitelist into its effective operation set (#3391). diff --git a/packages/spec/src/shared/external-errors.ts b/packages/spec/src/shared/external-errors.ts index 578c850305..2b06fa797e 100644 --- a/packages/spec/src/shared/external-errors.ts +++ b/packages/spec/src/shared/external-errors.ts @@ -42,7 +42,15 @@ export type SchemaDiffEntryKind = * definition than metadata declares (#3728). */ | 'index_mismatch' /** A physical index ObjectStack generated that metadata no longer declares. */ - | 'unmapped_index'; + | 'unmapped_index' + /** + * The column's physical DEFAULT is not the one metadata calls for — including + * "metadata calls for none". A column DEFAULT is data the DATABASE writes on + * the platform's behalf, so a wrong one is silently wrong rows, not a + * rejected write: a `defaultValue` runtime token emitted as a literal DEFAULT + * stamped the token's own spelling into every omitted insert (#4560). + */ + | 'default_mismatch'; /** * A single divergence entry. Produced by the validation gate (ADR §5.2)