Skip to content

Commit 3bc2e38

Browse files
authored
fix(driver-sql,platform-objects): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131) (#12686)
* wip(driver-sql,platform-objects): #12131 option D — delivery table id.type + sys_migration rider * fix(driver-sql,platform-objects): builtin-column delivery table speaks the spec field-type vocabulary (#12131)
1 parent 07e6465 commit 3bc2e38

6 files changed

Lines changed: 165 additions & 36 deletions

File tree

.changeset/builtin-column-collision-warning.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ only when the declaration asks for storage the platform's own column does not de
3838
(a differing `type`, a `maxLength`, `unique`, `defaultValue`, `storage.notNull`, a
3939
`multiple` shape…) and stays silent when it does not: `created_at: { type: 'datetime',
4040
defaultValue: 'NOW()' }` describes precisely what lands, and says nothing.
41-
`id: { type: 'number' }` — an author expecting a numeric key — still fires, as does
42-
`id: { type: 'text' }`. The storage/presentation split is one table
41+
`id: { type: 'number' }` — an author expecting a numeric key — still fires.
42+
`id: { type: 'text' }` does **not**: varchar(255) canonicalizes to the field type
43+
`text`, so that declaration asks for precisely what the column delivers (#12131
44+
the delivery table recorded the knex builder name `'string'` there at first, and
45+
reported all 45 of the platform's own correct `id` declarations as disagreements). The storage/presentation split is one table
4346
(`builtin-column-collision.ts`) pinned against `FieldSchema.shape`, so a field key
4447
added later is classified deliberately instead of defaulting into silence.
4548

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/driver-sql": patch
3+
"@objectstack/platform-objects": patch
4+
---
5+
6+
fix(driver-sql): the builtin-column delivery table speaks the spec's field-type vocabulary, not knex's builder names (#12131)
7+
8+
`BUILTIN_COLUMN_DELIVERY.id.type` recorded `'string'` — the **knex builder name** from
9+
`table.string('id').primary()` — and `undeliveredStorageAttributes` compares that value
10+
with `===` against a declaration's `type`, which is a spec `FieldType`. The two are
11+
different vocabularies, and `'string'` is not a member of the one being compared: it is
12+
absent from `FieldType`'s 49 options, `Field.string` is absent from the builder's keys,
13+
and `FieldSchema` refuses `type: 'string'` outright. So **no declaration could ever
14+
match it**, and the #12015 diagnostic reported every correct declaration on the
15+
platform's own key as a disagreement.
16+
17+
Measured on a stock boot of `@objectstack/platform-objects`: **45 warnings, one per
18+
system object**, each saying `type: 'text' (the column is 'string')` about a
19+
declaration that was right all along. `varchar` canonicalizes to the field type `text`
20+
(`canonicalizeSqlType('varchar(255)') === 'text'`, `suggestFieldTypeForSqlType('varchar(255)') === 'text'`,
21+
`isCompatible('varchar(255)', 'text') === true` — all pinned in `type-compat.test.ts`),
22+
so `id: Field.text(...)` asks for exactly what the platform's column delivers. The
23+
delivery table now records `text`, and the 45 lines go silent because they were false,
24+
not because they were suppressed.
25+
26+
`sys_migration.id`'s `maxLength: 128` was the one **honest** disagreement in that corpus
27+
— the column is varchar(255) — and it is removed rather than widened to 255. It bound
28+
nothing in any seam: the DDL discards a declared width on a builtin column name, and
29+
`validateRecord` skips `id` by name on both the insert and the update path (it is also
30+
`readonly`). Declaring a width that nothing enforces is the shape enforce-or-remove
31+
exists to prevent, and the 44 sibling system objects declare none.
32+
33+
The classification pin now holds **every** entry in the delivery table to
34+
`FieldType.options`, so a builder name written there fails by name instead of surfacing
35+
as a corpus of false warnings. The fixtures in both #12015 pin files were written
36+
against the delivery table rather than against the source — `sys_presence.id` was spelled
37+
`type: 'string'` in the "silent" cases, which is why they passed while the same
38+
declaration as actually written warned. They now use the shapes as declared, and the
39+
firing cases declare a type that genuinely disagrees.
40+
41+
**Grade: `patch` for both, and deliberately.** No door moves and no DDL changes: the
42+
platform still owns `id` / `created_at` / `updated_at`, the emitted column is
43+
byte-identical, every object that booted before still boots, and `BUILTIN_COLUMN_DELIVERY`
44+
is internal to the package (it is not re-exported from the package entry). The
45+
`platform-objects` half removes one metadata key that was measured inert in every seam
46+
that could read it. What changes is what the driver **says**.

packages/drivers/driver-sql/src/builtin-column-collision.test.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@
1717
* own column already provides is NOT a disagreement and must not be
1818
* reported as one — that is the whole content of the 2026-08-25 narrowing,
1919
* and the case that makes the message true again.
20+
*
21+
* ③ **The delivery table speaks the SPEC's vocabulary** (#12131). Its `type`
22+
* is compared with `===` against a declaration's `type`, so a knex builder
23+
* name there is a cross-vocabulary comparison that no declaration can
24+
* satisfy. `id` used to record `'string'` — the `table.string('id')`
25+
* builder name — and reported all 45 correct `id: Field.text(...)`
26+
* declarations in `@objectstack/platform-objects` as disagreements while
27+
* `'string'` was not even authorable. The first case below now holds every
28+
* entry to `FieldType`, so the class fails by name rather than by corpus.
2029
*/
2130

2231
import { describe, it, expect } from 'vitest';
23-
import { FieldSchema } from '@objectstack/spec/data';
32+
import { FieldSchema, FieldType } from '@objectstack/spec/data';
2433
import {
2534
FIELD_KEY_STORAGE_CLASS,
2635
BUILTIN_COLUMN_DELIVERY,
@@ -60,10 +69,30 @@ describe('the FieldSchema storage/presentation classification (#12015)', () => {
6069
}
6170
});
6271

72+
it('⛔ spells every delivered `type` in the SPEC vocabulary, never a knex builder name (#12131)', () => {
73+
// The one that got away: `id` recorded `'string'`, the knex builder name,
74+
// and `undeliveredStorageAttributes` compares it with `===` against a
75+
// declaration's `type` — a spec `FieldType`. No declaration could match it
76+
// (`'string'` is absent from FieldType's 49 members and `FieldSchema`
77+
// refuses it), so every correct `id: Field.text(...)` was reported as a
78+
// disagreement: 45 of them on a stock boot of platform-objects.
79+
for (const [column, delivery] of Object.entries(BUILTIN_COLUMN_DELIVERY)) {
80+
expect(
81+
FieldType.options as readonly string[],
82+
`BUILTIN_COLUMN_DELIVERY.${column}.type must be a spec FieldType, not a builder name`,
83+
).toContain(delivery.type);
84+
}
85+
});
86+
6387
it('records what each builtin column actually delivers, read off the emitting lines', () => {
64-
// `table.string('id').primary()` — varchar(255), NOT NULL, unique, no default.
88+
// `table.string('id').primary()` — varchar(255), NOT NULL, unique, no
89+
// default. varchar canonicalizes to the field type `text`
90+
// (`canonicalizeSqlType('varchar(255)') === 'text'`, pinned in
91+
// `type-compat.test.ts`), so `text` is what this column DELIVERS — which
92+
// is why the platform's own `id: Field.text(...)` declarations agree with
93+
// it exactly (#12131).
6594
expect(BUILTIN_COLUMN_DELIVERY.id).toMatchObject({
66-
type: 'string', maxLength: 255, unique: true, notNull: true, defaultValue: null,
95+
type: 'text', maxLength: 255, unique: true, notNull: true, defaultValue: null,
6796
});
6897
// `createAuditTimestampColumn` — a timestamp defaulted to the DB clock, left NULLABLE.
6998
for (const column of ['created_at', 'updated_at']) {
@@ -76,22 +105,30 @@ describe('the FieldSchema storage/presentation classification (#12015)', () => {
76105

77106
describe('what a declaration on a builtin column name loses (#12015)', () => {
78107
it('FIRES on the author error the card was filed for', () => {
79-
// `id: { type: 'number' }` — an author expecting a numeric key.
108+
// `id: { type: 'number' }` — an author expecting a numeric key. This is
109+
// the real author error; ⛔ NOT `{ type: 'text' }`, which is what the
110+
// column delivers (see the silent case below).
80111
expect(keysOf(undeliveredStorageAttributes('id', { type: 'number' }))).toEqual(['type']);
81-
// The #11456 fixture's shape.
82-
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', name: 'id' }))).toEqual(['type']);
112+
expect(keysOf(undeliveredStorageAttributes('id', { type: 'number', name: 'id' }))).toEqual(['type']);
83113
// …and names what the column really is, not just that something was lost.
84-
expect(undeliveredStorageAttributes('id', { type: 'text' })[0]).toMatchObject({
85-
key: 'type', declared: 'text', delivered: 'string',
114+
expect(undeliveredStorageAttributes('id', { type: 'number' })[0]).toMatchObject({
115+
key: 'type', declared: 'number', delivered: 'text',
86116
});
87117
});
88118

89119
it('is SILENT for a presentation-only declaration — the platform honours that half', () => {
90120
// `sys_presence.id`, verbatim in shape: the population the pre-narrowing
91-
// warning was false about.
121+
// warning was false about. ⚠️ It is `Field.text`, and this fixture used to
122+
// spell it `type: 'string'` — matching the delivery table's builder name
123+
// rather than the source. That made this case pass while the same
124+
// declaration as actually written warned (#12131). Verbatim now.
92125
expect(
93-
undeliveredStorageAttributes('id', { type: 'string', label: 'Presence ID', required: true, readonly: true }),
126+
undeliveredStorageAttributes('id', { type: 'text', label: 'Presence ID', required: true, readonly: true }),
94127
).toEqual([]);
128+
// The #11456 fixture's exact shape — the declaration that started #12015.
129+
// It asks for precisely what the column delivers, so it is SILENT; it was
130+
// reported as a disagreement until the delivery table was corrected.
131+
expect(undeliveredStorageAttributes('id', { type: 'text', name: 'id' })).toEqual([]);
95132
expect(
96133
undeliveredStorageAttributes('created_at', {
97134
type: 'datetime', label: 'Created At', defaultValue: 'NOW()', readonly: true,
@@ -100,21 +137,21 @@ describe('what a declaration on a builtin column name loses (#12015)', () => {
100137
});
101138

102139
it('is SILENT for a storage attribute the column already delivers', () => {
103-
expect(undeliveredStorageAttributes('id', { type: 'string', maxLength: 255 })).toEqual([]);
104-
expect(undeliveredStorageAttributes('id', { type: 'string', unique: true })).toEqual([]); // the PK is unique
105-
expect(undeliveredStorageAttributes('id', { type: 'string', storage: { notNull: true } })).toEqual([]); // the PK is NOT NULL
140+
expect(undeliveredStorageAttributes('id', { type: 'text', maxLength: 255 })).toEqual([]);
141+
expect(undeliveredStorageAttributes('id', { type: 'text', unique: true })).toEqual([]); // the PK is unique
142+
expect(undeliveredStorageAttributes('id', { type: 'text', storage: { notNull: true } })).toEqual([]); // the PK is NOT NULL
106143
expect(undeliveredStorageAttributes('created_at', { type: 'datetime', defaultValue: 'now()' })).toEqual([]); // token, case-insensitive
107144
});
108145

109146
it('FIRES for a storage attribute the column does NOT deliver, one entry each', () => {
110-
expect(keysOf(undeliveredStorageAttributes('id', { type: 'string', maxLength: 12 }))).toEqual(['maxLength']);
111-
expect(keysOf(undeliveredStorageAttributes('id', { type: 'string', defaultValue: 'NOW()' }))).toEqual(['defaultValue']);
147+
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', maxLength: 12 }))).toEqual(['maxLength']);
148+
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', defaultValue: 'NOW()' }))).toEqual(['defaultValue']);
112149
// created_at IS nullable and NOT unique — asking for either is a real disagreement.
113150
expect(keysOf(undeliveredStorageAttributes('created_at', { type: 'datetime', unique: true }))).toEqual(['unique']);
114151
expect(keysOf(undeliveredStorageAttributes('created_at', { type: 'datetime', storage: { notNull: true } })))
115152
.toEqual(['storage.notNull']);
116153
// Several at once, in declaration order.
117-
expect(keysOf(undeliveredStorageAttributes('id', { type: 'text', maxLength: 12, unique: false })))
154+
expect(keysOf(undeliveredStorageAttributes('id', { type: 'number', maxLength: 12, unique: false })))
118155
.toEqual(['type', 'maxLength']); // `unique: false` asks for nothing
119156
});
120157

@@ -125,7 +162,7 @@ describe('what a declaration on a builtin column name loses (#12015)', () => {
125162
it('stays silent — never throws — on a key it does not know, and on a malformed declaration', () => {
126163
// Forward compatibility: an unclassified key cannot invent a warning. The
127164
// exhaustiveness case above is what makes its arrival visible.
128-
expect(undeliveredStorageAttributes('id', { type: 'string', someFutureKey: 'x' } as any)).toEqual([]);
165+
expect(undeliveredStorageAttributes('id', { type: 'text', someFutureKey: 'x' } as any)).toEqual([]);
129166
expect(undeliveredStorageAttributes('id', undefined)).toEqual([]);
130167
expect(undeliveredStorageAttributes('id', null as any)).toEqual([]);
131168
});

packages/drivers/driver-sql/src/builtin-column-collision.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly<Record<string, FieldKeyClass>> =
158158
*
159159
* - `id` — `table.string('id').primary()`: varchar(255), NOT NULL,
160160
* PRIMARY KEY (so: unique), no column default (the engine
161-
* generates the key).
161+
* generates the key). varchar canonicalizes to the field
162+
* type `text`, which is what the table below records.
162163
* - `created_at` / `updated_at` — `createAuditTimestampColumn`: a timestamp
163164
* (MySQL `datetime(3)`) defaulted to the database clock,
164165
* left NULLABLE and stamped by the driver on every write.
@@ -168,7 +169,24 @@ export const FIELD_KEY_STORAGE_CLASS: Readonly<Record<string, FieldKeyClass>> =
168169
* defaultValue: 'NOW()' }` describes precisely what lands.
169170
*/
170171
export interface BuiltinColumnDelivery {
171-
/** The field type whose column this builtin actually is. */
172+
/**
173+
* The field type whose column this builtin actually is, spelled in the
174+
* SPEC's `FieldType` vocabulary — because that is the vocabulary a
175+
* declaration's `type` is written in, and this value is compared against it
176+
* with `===`.
177+
*
178+
* ⛔ NEVER the knex builder name. `id` is emitted by `table.string('id')`,
179+
* but knex's `string` IS varchar(255), and `canonicalizeSqlType('varchar(255)')`
180+
* is `'text'` — so the field type this column delivers is `'text'`, and
181+
* `isCompatible('varchar(255)', 'text')` is EXACT (both pinned in
182+
* `type-compat.test.ts`). Spelling the builder name here compares two
183+
* vocabularies and reports every correct `id: Field.text(...)` declaration
184+
* as a disagreement: measured at 45 false warnings on a stock boot of
185+
* `@objectstack/platform-objects`, on declarations that were right all
186+
* along. `'string'` is not even authorable — it is absent from `FieldType`'s
187+
* members and `FieldSchema` refuses it — so no declaration could have
188+
* silenced it.
189+
*/
172190
type: string;
173191
/** Fixed varchar width, when the column is bounded. */
174192
maxLength?: number;
@@ -181,7 +199,10 @@ export interface BuiltinColumnDelivery {
181199
}
182200

183201
export const BUILTIN_COLUMN_DELIVERY: Readonly<Record<string, BuiltinColumnDelivery>> = Object.freeze({
184-
id: Object.freeze({ type: 'string', maxLength: 255, unique: true, notNull: true, defaultValue: null }),
202+
// `table.string('id')` is knex's varchar(255); the FIELD TYPE it delivers is
203+
// `text` (see the vocabulary note on `BuiltinColumnDelivery.type` — do not
204+
// put the builder name back here).
205+
id: Object.freeze({ type: 'text', maxLength: 255, unique: true, notNull: true, defaultValue: null }),
185206
created_at: Object.freeze({ type: 'datetime', unique: false, notNull: false, defaultValue: 'NOW()' as const }),
186207
updated_at: Object.freeze({ type: 'datetime', unique: false, notNull: false, defaultValue: 'NOW()' as const }),
187208
});

0 commit comments

Comments
 (0)