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
57 changes: 57 additions & 0 deletions .changeset/ddl-runtime-token-default.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 113 additions & 0 deletions packages/objectql/src/engine-default-value-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, Record<string, unknown>>>();
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<string, unknown>) {
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<string, unknown>) { return this.create(object, data); },
async delete() { return true; },
async count(object: string) { return storeFor(object).size; },
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
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');
});
});
11 changes: 9 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
81 changes: 80 additions & 1 deletion packages/plugins/driver-sql/src/schema-drift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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) &&
Expand Down
Loading
Loading