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
64 changes: 64 additions & 0 deletions .changeset/select-option-default-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@objectstack/objectql": minor
---

feat(objectql): the option marked `default: true` is now the field's default on insert (#7246)

`SelectOption.default` has been authorable and spec-valid since the schema was
written, and nothing on the insert path read it. `ObjectQL.applyFieldDefaults`
resolved `f.defaultValue` — Expression envelopes, the `DEFAULT_VALUE_TOKENS`
family, then static literals — and never looked at `options`. So this, which
reads like a declaration of the initial value:

```ts
status: Field.select({
label: 'Status',
options: [
{ label: 'Draft', value: 'draft', default: true },
{ label: 'Approved', value: 'approved' },
],
}),
```

stored **null** on a create that omitted the field, not `draft`.

The key's one consumer anywhere in the repo was lint's `isNullableField`, which
concluded from it that the column was **always valued** — and that verdict is
build-breaking. So the single place that read the key trusted it, while the
place that would have made it true ignored it: a predicate over such a field
could be silenced by a heuristic resting on a declaration nothing honoured.

**After** (maintainer ruling on #7246, ADR-0049 enforce leg): a field that
declares no `defaultValue` falls back to the option marked `default: true`, on
every driver, resolved by the engine exactly as the token family is.

- **`defaultValue` wins when both are declared** — the more specific
declaration. It names a value for *this* field; the option flag describes the
shared option list. When the two disagree the flag stays inert, as it was
everywhere before.
- **Presence is the engine's own `dv == null` test.** `defaultValue: ''` is a
real default and still wins; the fallback fires only when `defaultValue` is
absent by that test.
- **The fallback resolves in the `defaultValue == null` arm**, downstream of the
token and envelope branches, so an option value is always a plain literal — an
option spelled `current_user` stores those twelve characters rather than the
acting user's id.
- **`multiple: true` assembles an array** of every marked option in declaration
order, because that field stores an Array/JSON; a single-valued field with
several marked options takes the first.
- **No physical column DEFAULT** is emitted for an option-default. The engine is
the one place the two spellings are ranked, the multi-select shape has no
scalar DDL form, and emitting would give new databases a default that older
ones on identical metadata lack with nothing to report the divergence. The
reasoning is recorded on `SqlDriver.applyDeclaredColumnDefault` and pinned by
test.

**Migration.** Metadata declaring an option `default: true` on a field with no
`defaultValue` changes insert behaviour: records that used to be born with a
null in that column are now born with the marked option. That is the behaviour
the declaration always described. In the shipped corpus this covers 30 fields
across the showcase, CRM and todo example apps and the downstream-contract QA
fixture — all of them status/stage/priority selects whose marked option is the
intended initial state. To keep the previous behaviour, drop the `default: true`
flag from the option; to make the value explicit, declare `defaultValue`
alongside it, which now outranks the flag.
29 changes: 27 additions & 2 deletions examples/app-showcase/test/hook-body-persisted-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,17 @@ describe('#7258 — app-showcase sandboxed `body` hooks reach the persisted row'

const stored = await readBack(engine, 'showcase_inquiry', String(created.id));
// The record's own fields are what a body enumerates...
expect(stored.company).toBe('KEYS[email,message,name] hasData=undefined');
//
// `status` is in that list without any hook having run, and is not the
// caller's (#7246): `applyFieldDefaults` runs at the TOP of the insert
// middleware, so engine-resolved defaults are already on the record before
// the first `beforeInsert` body sees it. `showcase_inquiry.status` marks
// `{ value: 'new', default: true }`, and the engine now honours that option
// flag exactly as it has always honoured a `defaultValue`. The priority-10
// ordering this case is really about is unaffected — `source` is absent
// here precisely because it is stamped by `StampInquiryDefaultsHook` at
// priority 50, which still has not run.
expect(stored.company).toBe('KEYS[email,message,name,status] hasData=undefined');
// ...and the write the probe made through that flat view landed in the row,
// which is the second half of `installFlatInput`'s contract.
expect(stored.status).toBe('new');
Expand All @@ -223,8 +233,23 @@ describe('#7258 — app-showcase sandboxed `body` hooks reach the persisted row'
ctx as never,
);
const storedInquiry = await readBack(engine, 'showcase_inquiry', String(inquiry.id));
expect(storedInquiry.status == null).toBe(true);
// `source` is the whole vacuity witness for this hook now (#7246).
//
// It used to be that BOTH of the hook's stamps came back null with the hook
// unbound. `status` no longer does, and not because a hook ran: the field
// declares `{ value: 'new', default: true }`, and the ENGINE now applies
// that option default on insert. So `status` can no longer discriminate
// "the hook ran" from "the hook did not" — it reads `'new'` either way, and
// asserting `'new'` here would make this half of the reverse check assert
// the same fact as the forward case, i.e. nothing.
//
// `source` still can: `showcase_inquiry.source` is a plain `Field.text`
// with no options and no `defaultValue`, so `'web'` has exactly one
// producer — `StampInquiryDefaultsHook`. The pin therefore moves onto it
// rather than being softened, and the engine-owned value is asserted
// separately for what it now is.
expect(storedInquiry.source == null).toBe(true);
expect(storedInquiry.status).toBe('new'); // engine option default, NOT the hook

const account: any = await engine.insert(
'showcase_account', { name: 'Initech', status: 'active' }, ctx as never,
Expand Down
124 changes: 124 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-option-default-ddl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* An option-level `default: true` gets NO physical column DEFAULT (#7246).
*
* The engine now honours the select idiom — `ObjectQL.applyFieldDefaults` falls
* back to the option marked `default: true` when the field declares no
* `defaultValue`. The DDL deliberately does NOT follow, and because that reads
* as an oversight next to case 4 of `applyDeclaredColumnDefault` (an ordinary
* literal IS emitted), it is pinned here rather than left to a comment.
*
* Why not emit, in short — the long form lives on
* `SqlDriver.applyDeclaredColumnDefault`:
*
* - `defaultValue` beats the option flag, and that precedence lives in ONE
* place, the engine. A column DEFAULT is a second resolver.
* - On `multiple: true` the default is an ARRAY; there is no scalar DDL form,
* so emitting needs a carve-out the author cannot see.
* - This method runs for fresh and re-materialized columns only, never a
* retrofit, so emitting would give new databases a DEFAULT that older ones
* on identical metadata lack — and `detectDrift`'s only `default_mismatch`
* producer is the #4560 runtime-token check, so nothing would report it.
*
* The #4560 discipline is NOT the reason: an option's `value` is a plain
* literal, so it could legally be emitted. This is a design decision about
* where a default is resolved, and the last test states the consequence that
* makes it safe — every ObjectStack write path stores the value regardless,
* because the engine, not the database, supplies it.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver — an option `default: true` never becomes a column DEFAULT (#7246)', () => {
let knexInstance: any;

const makeDriver = () => {
const d = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
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<string> => {
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 optionZoo = [
{
name: 'option_zoo',
fields: {
title: { type: 'string' },
// Option-default only — the shape 30 fields in the shipped corpus use.
f_status: {
type: 'select',
options: [
{ label: 'Draft', value: 'draft', default: true },
{ label: 'Active', value: 'active' },
],
},
// BOTH declared: the field-level literal is emitted (case 4), and it is
// also the value the engine resolves — the two agree, which is the
// property that matters when both sides can answer.
f_stage: {
type: 'select',
defaultValue: 'approved',
options: [
{ label: 'Draft', value: 'draft', default: true },
{ label: 'Approved', value: 'approved' },
],
},
},
},
];

it('creates an option-defaulted column with NO database default', async () => {
const driver = makeDriver();
await driver.initObjects(optionZoo as any);

const info = await knexInstance('option_zoo').columnInfo();
expect(info.f_status.defaultValue ?? null).toBeNull();

const sql = await tableSql('option_zoo');
expect(sql).not.toContain("DEFAULT 'draft'");
});

it('REGRESSION: a field-level `defaultValue` on the SAME field is still emitted', async () => {
// The exclusion is scoped to the option flag. Losing case 4 here would be a
// silent behaviour change for every literal default in the platform.
const driver = makeDriver();
await driver.initObjects(optionZoo as any);
const info = await knexInstance('option_zoo').columnInfo();
expect(String(info.f_stage.defaultValue)).toContain('approved');
// ...and never the option the field-level key outranks.
expect(String(info.f_stage.defaultValue)).not.toContain('draft');
});

it('a driver-level insert that omits the field stores NULL — the engine, not the database, defaults it', async () => {
// The consequence of the decision, stated rather than left implicit: this
// is a RAW driver write, below the engine. Through `ObjectQL.insert` the
// same omission stores 'draft' (pinned in
// objectql/src/engine-select-option-default.test.ts), which is every
// ObjectStack write path. Only a writer bypassing the engine sees this
// NULL — and that writer is not reading `options` either.
const driver = makeDriver();
await driver.initObjects(optionZoo as any);
await driver.create('option_zoo', { id: 'o1', title: 't' }, { bypassTenantAudit: true });
const row = await knexInstance('option_zoo').where('id', 'o1').first();
expect(row.f_status).toBeNull();
});
});
35 changes: 35 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8936,6 +8936,41 @@ export class SqlDriver implements IDataDriver {
* 3. **Objects** — Expression envelopes (`{ dialect, source }`), evaluated
* app-side; never a column DEFAULT.
* 4. **Everything else** — a real literal, emitted verbatim.
*
* # What this deliberately does NOT read: `options[].default` (#7246)
*
* A select field's option-level `default: true` is now a real initial value —
* `ObjectQL.applyFieldDefaults` falls back to the marked option when the field
* declares no `defaultValue`. It gets NO physical column DEFAULT here, and
* that asymmetry with case 4 is the decision, not an omission.
*
* The #4560 discipline does not forbid it: an option's `value` is a plain
* literal, not a runtime token, so it *could* be emitted. The reasons not to:
*
* - **One resolver owns the precedence.** `defaultValue` beats the option
* flag, and that ordering lives in the engine. A column DEFAULT is a
* second resolver that fires only where the engine did not, so keeping it
* out means there is exactly one place the question is answered.
* - **The shape has no scalar DDL form.** On `multiple: true` the default is
* an ARRAY of the marked options. Emitting would need a "…except for
* multi-selects" carve-out invisible to the author.
* - **It would divide deployments silently.** This method runs for a FRESH
* column ({@link createColumn}) and a re-materialized one
* ({@link rebuildSqliteTablePatched}) — never a retrofit of an existing
* table. Emitting would give new databases (and any SQLite rebuild) a
* DEFAULT that older databases on identical metadata lack, and
* `detectDrift`'s only `default_mismatch` producer is the #4560 token
* check — there is no general declared-literal-vs-physical comparison — so
* nothing would ever report the divergence.
* - **It would make SQL the odd driver out.** The engine fallback serves
* memory, mongodb, sql and turso identically from day one; a SQL-only
* second enforcement point is exactly the per-datasource split #4597 and
* #4560 were both about.
*
* The value the engine resolves is what every ObjectStack write path stores;
* only a writer bypassing the engine entirely sees NULL, and that writer is
* not reading `options` either. Pinned by
* `sql-driver-option-default-ddl.test.ts`.
*/
protected applyDeclaredColumnDefault(col: Knex.ColumnBuilder, field: any, type: string): void {
const dv = field?.defaultValue;
Expand Down
12 changes: 12 additions & 0 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,18 @@ function fieldEntries(obj: AnyRec): Array<[string, AnyRec]> {
* always-valued when it is `required`, carries a `defaultValue`, declares a
* default option (`options: [{ …, default: true }]` — the select idiom), or is
* an autonumber the platform populates.
*
* The select-idiom branch is grounded, not assumed (#7246). It was the only
* consumer of `SelectOption.default` anywhere in the repo while the insert path
* ignored the key entirely, so it concluded "always valued" about a column that
* really did store `null` — a build-breaking verdict resting on a declaration
* nothing honoured, and a predicate reading such a field could be silenced by
* it. `ObjectQL.applyFieldDefaults` now falls back to the marked option when
* the field declares no `defaultValue`, so the premise this branch always
* stated is true on the write path. The two sides are kept honest BY
* CONSTRUCTION: the engine reads the canonical `default` spelling, without a
* `type` test, exactly as this does — so there is no field this calls
* always-valued that the engine leaves empty.
*/
function isNullableField(def: AnyRec): boolean {
if (def.required === true) return false;
Expand Down
Loading
Loading