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
51 changes: 51 additions & 0 deletions .changeset/builtin-column-collision-warning.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
---
"@objectstack/driver-sql": patch
---

fix(driver-sql): name the storage a declaration on a builtin column name loses, instead of discarding it in silence (#12015)

`initObjects` emits `id`, `created_at` and `updated_at` itself and then skips any
declared field colliding with one — `if (builtinColumns.has(name)) continue;`, with
no warning, no throw and no record anywhere that the author's declaration had been
dropped. Measured on live PostgreSQL 16.13: an object declaring
`id: { type: 'text' }` boots green and gets `id varchar(255)` — `table.string('id')`,
not TEXT. Measured here on SQLite: the same substitution, and a declared
`maxLength: 12` on that field binds nothing. The driver is right to own its primary
key and audit stamps; the defect was that it disagreed with the author in silence —
the declared-≠-enforced shape that bites hardest on AI-authored metadata, where the
mismatch surfaces much later as data behaving oddly.

Every DDL path that drops such a declaration now says so, naming the field, the
object, the attributes that were lost and what the platform's column actually is:

- **create** — `while creating table "…"`, said before the CREATE runs, so the
author hears it even when the CREATE goes on to fail for an unrelated reason;
- **ADD COLUMN diff** — `while syncing existing table "…"`; this path drops the
declaration for a different reason (the builtin is already in the table, so the
diff never proposes it), and it is the path a stock upgrade takes;
- **rotation shard** — `while syncing shard "…"`, covering both the shard-create and
shard-column-sync branches.

A warning on one path with silence on the others just moves the trap, so each path
carries its own call and its own pin: a regression to a silent `continue` on one path
fails by name rather than being absorbed by a sibling.

**Only the STORAGE half is reported, because only the storage half is lost.** A
declaration on a builtin column name still carries `label` (and the locales generated
from it), `readonly`, `searchable` and the ADR-0113 write contract in `required` — all
honoured on the platform's column exactly as on any other. So the diagnostic fires
only when the declaration asks for storage the platform's own column does not deliver
(a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a
`multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime',
defaultValue: 'NOW()' }` describes precisely what lands, and says nothing.
`id: { type: 'number' }` — an author expecting a numeric key — still fires, as does
`id: { type: 'text' }`. The storage/presentation split is one table
(`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key
added later is classified deliberately instead of defaulting into silence.

**Grade: `patch`, and deliberately.** Nothing about the accept set moves — every
object that booted before still boots, the DDL emitted is byte-identical, no public
type or metadata key changes, and the only observable difference is a line in the log
for storage that was already being discarded. The platform still owns `id` /
`created_at` / `updated_at`: this changes what the driver **says**, never what it
**does**.
132 changes: 132 additions & 0 deletions packages/drivers/driver-sql/src/builtin-column-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #12015 — the storage/presentation split itself, pinned.
*
* The diagnostic in `SqlDriver` fires on what this module decides, so the
* decision is worth more than the plumbing around it. Two claims live here:
*
* ① **The classification is exhaustive over `FieldSchema`.** A key added to
* the spec later fails the first case until someone classifies it — the
* whole point of keeping the table in one place. At runtime an unclassified
* key is silent (a diagnostic must never invent a warning it cannot
* justify), so without this pin an added key would default into silence
* with nothing to notice it.
*
* ② **Delivered means delivered.** A declared storage attribute the platform's
* own column already provides is NOT a disagreement and must not be
* reported as one — that is the whole content of the 2026-08-25 narrowing,
* and the case that makes the message true again.
*/

import { describe, it, expect } from 'vitest';
import { FieldSchema } from '@objectstack/spec/data';
import {
FIELD_KEY_STORAGE_CLASS,
BUILTIN_COLUMN_DELIVERY,
undeliveredStorageAttributes,
} from './builtin-column-collision.js';

/** Just the keys, for readability in the assertions below. */
const keysOf = (attrs: ReturnType<typeof undeliveredStorageAttributes>) => attrs.map((a) => a.key);

describe('the FieldSchema storage/presentation classification (#12015)', () => {
it('classifies EVERY FieldSchema key, and invents none', () => {
const declared = Object.keys(FieldSchema.shape).sort();
const classified = Object.keys(FIELD_KEY_STORAGE_CLASS).sort();

// A spec key with no classification: it would be silent at runtime, which
// is safe but undeliberate. Classify it in `FIELD_KEY_STORAGE_CLASS`.
expect(declared.filter((k) => !classified.includes(k)), 'unclassified FieldSchema key(s)').toEqual([]);
// A classification with no spec key: dead weight that reads as coverage.
expect(classified.filter((k) => !declared.includes(k)), 'classified key(s) FieldSchema does not declare').toEqual([]);
});

it('puts `required` on the PRESENTATION side — ADR-0113 makes it the WRITE contract, not a column constraint', () => {
// The load-bearing classification: `required: true` appears on nearly every
// platform object's `id`, the engine enforces it there exactly as anywhere
// else, and calling it "discarded" is the false sentence this card removed.
expect(FIELD_KEY_STORAGE_CLASS.required).toBe('presentation');
// Its ADR-0113 sibling — the one that IS the column constraint.
expect(FIELD_KEY_STORAGE_CLASS.storage).toBe('storage');
});

it('puts the honoured half on the presentation side and the column shape on the storage side', () => {
for (const key of ['label', 'readonly', 'searchable', 'description', 'inlineHelpText', 'group', 'name']) {
expect(FIELD_KEY_STORAGE_CLASS[key], `${key} is honoured on a builtin column`).toBe('presentation');
}
for (const key of ['type', 'maxLength', 'unique', 'defaultValue', 'multiple', 'expression']) {
expect(FIELD_KEY_STORAGE_CLASS[key], `${key} shapes the physical column`).toBe('storage');
}
});

it('records what each builtin column actually delivers, read off the emitting lines', () => {
// `table.string('id').primary()` — varchar(255), NOT NULL, unique, no default.
expect(BUILTIN_COLUMN_DELIVERY.id).toMatchObject({
type: 'string', maxLength: 255, unique: true, notNull: true, defaultValue: null,
});
// `createAuditTimestampColumn` — a timestamp defaulted to the DB clock, left NULLABLE.
for (const column of ['created_at', 'updated_at']) {
expect(BUILTIN_COLUMN_DELIVERY[column]).toMatchObject({
type: 'datetime', unique: false, notNull: false, defaultValue: 'NOW()',
});
}
});
});

describe('what a declaration on a builtin column name loses (#12015)', () => {
it('FIRES on the author error the card was filed for', () => {
// `id: { type: 'number' }` — an author expecting a numeric key.
expect(keysOf(undeliveredStorageAttributes('id', { type: 'number' }))).toEqual(['type']);
// The #11456 fixture's shape.
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', name: 'id' }))).toEqual(['type']);
// …and names what the column really is, not just that something was lost.
expect(undeliveredStorageAttributes('id', { type: 'text' })[0]).toMatchObject({
key: 'type', declared: 'text', delivered: 'string',
});
});

it('is SILENT for a presentation-only declaration — the platform honours that half', () => {
// `sys_presence.id`, verbatim in shape: the population the pre-narrowing
// warning was false about.
expect(
undeliveredStorageAttributes('id', { type: 'string', label: 'Presence ID', required: true, readonly: true }),
).toEqual([]);
expect(
undeliveredStorageAttributes('created_at', {
type: 'datetime', label: 'Created At', defaultValue: 'NOW()', readonly: true,
}),
).toEqual([]);
});

it('is SILENT for a storage attribute the column already delivers', () => {
expect(undeliveredStorageAttributes('id', { type: 'string', maxLength: 255 })).toEqual([]);
expect(undeliveredStorageAttributes('id', { type: 'string', unique: true })).toEqual([]); // the PK is unique
expect(undeliveredStorageAttributes('id', { type: 'string', storage: { notNull: true } })).toEqual([]); // the PK is NOT NULL
expect(undeliveredStorageAttributes('created_at', { type: 'datetime', defaultValue: 'now()' })).toEqual([]); // token, case-insensitive
});

it('FIRES for a storage attribute the column does NOT deliver, one entry each', () => {
expect(keysOf(undeliveredStorageAttributes('id', { type: 'string', maxLength: 12 }))).toEqual(['maxLength']);
expect(keysOf(undeliveredStorageAttributes('id', { type: 'string', defaultValue: 'NOW()' }))).toEqual(['defaultValue']);
// created_at IS nullable and NOT unique — asking for either is a real disagreement.
expect(keysOf(undeliveredStorageAttributes('created_at', { type: 'datetime', unique: true }))).toEqual(['unique']);
expect(keysOf(undeliveredStorageAttributes('created_at', { type: 'datetime', storage: { notNull: true } })))
.toEqual(['storage.notNull']);
// Several at once, in declaration order.
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', maxLength: 12, unique: false })))
.toEqual(['type', 'maxLength']); // `unique: false` asks for nothing
});

it('ignores a field that is not a builtin column name at all', () => {
expect(undeliveredStorageAttributes('region', { type: 'text', maxLength: 12 })).toEqual([]);
});

it('stays silent — never throws — on a key it does not know, and on a malformed declaration', () => {
// Forward compatibility: an unclassified key cannot invent a warning. The
// exhaustiveness case above is what makes its arrival visible.
expect(undeliveredStorageAttributes('id', { type: 'string', someFutureKey: 'x' } as any)).toEqual([]);
expect(undeliveredStorageAttributes('id', undefined)).toEqual([]);
expect(undeliveredStorageAttributes('id', null as any)).toEqual([]);
});
});
Loading
Loading