Skip to content

Commit ee370d3

Browse files
os-trumpclaude
andauthored
fix(cli): multiple: true takes a JSON column in both migration generators (#14990)
* test(cli): pin `multiple: true` agreement across the three generate surfaces Red-first. The pin drives `os generate types`, `os generate migration --format sql` and `os generate migration` (typescript) on one config and asserts they give a flagged field ONE answer — an array TS type and a JSON column — mirroring `driver-sql`'s `createColumn`, which decides `multiple` before its per-type switch. The three generator functions gain a named export so the pin can drive them directly; `src/commands/generate.ts` is not a package entrypoint (`@objectstack/cli` exports only `.` and `./console`), so nothing is added to the published surface, and no emitted output changes in this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza * fix(cli): `multiple: true` takes a JSON column in both migration generators `os generate types` honoured the flag and neither migration generator did, so one authored `Field.lookup({ multiple: true })` produced an array TS type and a scalar `VARCHAR(36)` / `table.uuid` column from the same config in the same run. The column authority is `driver-sql`, and its answer is the flag alone: `createColumn` short-circuits on `field.multiple` above its own per-type switch, `isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`, and `fieldHasColumn` opens with the same check. Both generators now answer it in the same place — before the type is consulted — so the element type gets no vote. Deliberately NOT the spec's `isMultiValueField`: that is the ADR-0104 D1 value contract, gated on `MULTI_CAPABLE_TYPES`, and it would answer VARCHAR for a `text` field the driver gives a JSON column — the same drift one notch narrower. The per-type vocabularies are untouched; the disputed scalar answers stay byte-for-byte and are pinned as a scope fence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33681ea commit ee370d3

5 files changed

Lines changed: 345 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os generate migration` now gives a `multiple: true` field a JSON column, in both formats. One authored field used to produce two incompatible answers from one config in one run: `Field.lookup({ reference: 'account', multiple: true })` emitted `account?: string[]` from `os generate types` and a scalar `VARCHAR(36)` / `table.uuid('account')` column from the two migration generators, because `multiple` appeared exactly four times in `generate.ts` and all four were on the TypeScript side — `fieldTypeToSql` did not even take the parameter. Nothing warned: the scaffold looks right, the generated TypeScript IS right, and only the column is wrong, so the first symptom was a write of an array into a scalar column. That is the `#field-zoo` failure one layer out — there the DDL switch and `isJsonField` had drifted into two lists inside the driver; here the platform and the *generated* DDL were the two lists.
6+
7+
The authority is the driver's, and it is the flag alone. `SqlDriver.createColumn` short-circuits on `field.multiple` **above** its own `switch (type)`; `isJsonField` is `JSON_COLUMN_TYPES.has(type) || !!field.multiple`; and `fieldHasColumn` opens with `if (field?.multiple) return true` under the comment "Mirrors `SqlDriver.createColumn` exactly … including `multiple` (a JSON column)". Three statements of one rule: a flagged field is a JSON column whatever its element type would have been. Both generators now answer it the same way and in the same place — before the type is consulted at all.
8+
9+
Deliberately **not** the spec's `isMultiValueField`. That predicate is the ADR-0104 D1 *value* contract ("is the persisted value an array") and gates on `MULTI_CAPABLE_TYPES`, so asking it here would answer `VARCHAR` for a `text` field flagged `multiple: true` while the driver gives that same field a JSON column — the identical drift one notch narrower. `FieldSchema` does not refuse the combination either (`multiple` is a plain `z.boolean()` on every field; only `radio` + `multiple` is refused by name), and the generators sit downstream of validation. The two questions have two different owners: the value shape is the spec's, the column is the driver's.
10+
11+
Nothing about the existing per-type vocabularies changes. The scalar answers — including the five that are separately disputed — are byte-for-byte what they were, and a new pin asserts that as a scope fence rather than leaving it to a reading of the diff. `generate-multiple-json-column.pin.test.ts` drives all three generators on one config and pins the agreement across every member of the spec's `MULTI_CAPABLE_TYPES` plus a type outside it, so the type-blindness of the rule is an assertion rather than a comment; it also reads the driver's two statements of the rule, so moving them there fails here.
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* THE #14829 PIN: one authored `multiple: true` field, three surfaces, ONE answer.
5+
*
6+
* ## The defect
7+
*
8+
* `multiple` appeared exactly FOUR times in `generate.ts`, and all four were on
9+
* the TypeScript side (measured at `origin/main` 5bc2f2727a:
10+
* `git grep -n multiple origin/main -- packages/cli/src/commands/generate.ts`):
11+
*
12+
* :562 function fieldTypeToTs(fieldType: string, multiple?: boolean)
13+
* :564 return multiple ? `${base}[]` : base;
14+
* :607 const tsType = fieldTypeToTs(fType, !!fieldDef.multiple); // os generate types
15+
* :831 const tsType = fieldTypeToTs(fType, !!fieldDef.multiple); // os generate client
16+
*
17+
* Neither migration generator read it, and `fieldTypeToSql` did not even take
18+
* the parameter. So ONE authored field produced two incompatible answers from
19+
* one config in one run — `Field.lookup({ reference: 'account', multiple: true })`
20+
* emitted `account?: string[]` from `os generate types` and a scalar
21+
* `VARCHAR(36)` / `table.uuid('account')` column from the two migration
22+
* generators. Nothing warns: the scaffold looks right, the generated
23+
* TypeScript IS right, and only the column is wrong, so the first symptom is a
24+
* write. That is the `#field-zoo` failure one layer out — there the DDL switch
25+
* and `isJsonField` had drifted into two lists inside the driver; here the
26+
* platform and the GENERATED DDL are the two lists.
27+
*
28+
* ## Which surface is authoritative, and why it is NOT `isMultiValueField`
29+
*
30+
* Measured on `origin/main`, the platform answers "which column does this field
31+
* get" from the FLAG ALONE, before it looks at the type, and says so in three
32+
* places:
33+
*
34+
* packages/drivers/driver-sql/src/sql-driver.ts `createColumn`
35+
* `if (field.multiple) { this.jsonColumn(table, name); return; }` — stated
36+
* above the `switch (type)`, so the element type never gets a vote.
37+
* packages/drivers/driver-sql/src/sql-driver.ts `isJsonField`
38+
* `JSON_COLUMN_TYPES.has(type) || !!field.multiple`
39+
* packages/drivers/driver-sql/src/schema-drift.ts `fieldHasColumn`
40+
* `if (field?.multiple) return true;` — under the comment "Mirrors
41+
* `SqlDriver.createColumn` exactly … everything else — including `multiple`
42+
* (a JSON column) — gets one."
43+
*
44+
* The spec's `isMultiValueField` is a DIFFERENT question with a different
45+
* answer: it is the ADR-0104 D1 VALUE contract ("is the persisted value an
46+
* array"), and it gates on `MULTI_CAPABLE_TYPES` —
47+
* `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && multiple)`.
48+
* A generator that asked it instead would answer VARCHAR for a `text` field
49+
* flagged `multiple: true` while the driver gives that same field a JSON
50+
* column — reintroducing this very drift one notch narrower. `FieldSchema`
51+
* does not refuse the combination either (`multiple` is a plain
52+
* `z.boolean().default(false)` on every field; only `radio` + `multiple` is
53+
* refused, by name, in `field.zod.ts`'s superRefine), and the CLI generators
54+
* sit DOWNSTREAM of validation and explicitly serve the unvalidated authoring
55+
* door. So the column authority is the driver's flag-first rule, and this pin
56+
* asserts against that.
57+
*
58+
* `MULTI_CAPABLE_TYPES` is still imported here rather than transcribed — it is
59+
* the roster this pin SWEEPS, so a type added to that spec class is measured on
60+
* the day it lands. It is not the implementation's gate, and the type-blindness
61+
* control below is what states the difference as an assertion.
62+
*
63+
* ## Anti-vacuity
64+
*
65+
* Every arm has a control, because a pin that measured nothing would pass
66+
* loudest of all. The controls are separate `it` blocks with `control —` in
67+
* their names, so a red run says in its own title whether the discriminating
68+
* arm fired or merely the harness: the roster really loaded, the generators
69+
* really emitted, and — the one that matters — the SAME type WITHOUT the flag
70+
* still gets its scalar column, so "JSONB everywhere" cannot pass this file.
71+
*/
72+
73+
import fs from 'node:fs';
74+
import path from 'node:path';
75+
import { fileURLToPath } from 'node:url';
76+
77+
import { MULTI_CAPABLE_TYPES } from '@objectstack/spec/data';
78+
import { describe, expect, it } from 'vitest';
79+
80+
import {
81+
generateMigrationSql,
82+
generateMigrationTs,
83+
generateTypesFromConfig,
84+
} from './generate.js';
85+
86+
const HERE = path.dirname(fileURLToPath(import.meta.url));
87+
const DRIVER_SQL_SRC = path.resolve(HERE, '../../../drivers/driver-sql/src');
88+
89+
/**
90+
* The types swept for the flag. The spec's multi-capable roster (imported, not
91+
* restated) plus `text` — a type that is NOT in that roster and whose scalar
92+
* answer is a varchar, which is what makes the type-blindness of the rule
93+
* assertable rather than merely described.
94+
*/
95+
const FLAGGED_TYPES: readonly string[] = [...MULTI_CAPABLE_TYPES, 'text'];
96+
97+
/** One object carrying, for each swept type, a flagged field and its scalar twin. */
98+
function probeConfig(): Record<string, unknown> {
99+
const fields: Record<string, Record<string, unknown>> = {};
100+
for (const type of FLAGGED_TYPES) {
101+
fields[`multi_${type}`] = { type, multiple: true };
102+
fields[`single_${type}`] = { type };
103+
}
104+
return { objects: { probe: { name: 'probe', label: 'Probe', fields } } };
105+
}
106+
107+
const TYPES_OUT = generateTypesFromConfig(probeConfig());
108+
const SQL_OUT = generateMigrationSql(probeConfig());
109+
const TS_OUT = generateMigrationTs(probeConfig());
110+
111+
/** The `"name" TYPE` column body one field contributes to the SQL migration. */
112+
function sqlColumn(field: string): string {
113+
const m = SQL_OUT.match(new RegExp(`^ {2}"${field}" (.+?),?$`, 'm'));
114+
if (!m) throw new Error(`no SQL column emitted for ${field}`);
115+
return m[1];
116+
}
117+
118+
/** The `table.x('name')…` call one field contributes to the TS migration. */
119+
function tsColumn(field: string): string {
120+
const m = TS_OUT.match(new RegExp(`^ {4}(table\\.\\w+\\('${field}'\\)).*$`, 'm'));
121+
if (!m) throw new Error(`no TS migration column emitted for ${field}`);
122+
return m[1];
123+
}
124+
125+
/** The declared property type one field contributes to the generated interface. */
126+
function tsInterfaceType(field: string): string {
127+
const m = TYPES_OUT.match(new RegExp(`^ {2}${field}\\??: (.+);$`, 'm'));
128+
if (!m) throw new Error(`no interface member emitted for ${field}`);
129+
return m[1];
130+
}
131+
132+
describe('#14829 — `multiple: true` is one answer across all three surfaces', () => {
133+
it('control — the spec multi-capable roster really loaded', () => {
134+
expect(MULTI_CAPABLE_TYPES.size).toBeGreaterThanOrEqual(6);
135+
for (const known of ['select', 'lookup', 'user', 'file', 'image']) {
136+
expect(MULTI_CAPABLE_TYPES.has(known)).toBe(true);
137+
}
138+
// `text` is the type-blindness probe: it must NOT be in the roster, or the
139+
// control below stops distinguishing the flag rule from the value rule.
140+
expect(MULTI_CAPABLE_TYPES.has('text')).toBe(false);
141+
});
142+
143+
it('control — all three generators really emitted a table for the probe', () => {
144+
expect(TYPES_OUT).toContain('export interface ProbeRecord {');
145+
expect(SQL_OUT).toContain('CREATE TABLE IF NOT EXISTS "probe" (');
146+
expect(TS_OUT).toContain("await db.schema.createTable('probe'");
147+
// Non-vacuity for the readers: every swept field really reached the output.
148+
expect(FLAGGED_TYPES.length).toBeGreaterThanOrEqual(7);
149+
for (const type of FLAGGED_TYPES) {
150+
expect(() => sqlColumn(`multi_${type}`)).not.toThrow();
151+
expect(() => tsColumn(`multi_${type}`)).not.toThrow();
152+
expect(() => tsInterfaceType(`multi_${type}`)).not.toThrow();
153+
}
154+
});
155+
156+
it('control — the SAME type without the flag still gets its scalar column', () => {
157+
// THE discriminating control. If this file could be satisfied by emitting a
158+
// JSON column for everything, the arms below would prove nothing.
159+
expect(sqlColumn('single_lookup')).toBe('VARCHAR(36)');
160+
expect(tsColumn('single_lookup')).toBe("table.uuid('single_lookup')");
161+
expect(sqlColumn('single_text')).toBe('VARCHAR(255)');
162+
expect(tsColumn('single_text')).toBe("table.string('single_text')");
163+
expect(tsInterfaceType('single_lookup')).toBe('string');
164+
});
165+
166+
for (const type of FLAGGED_TYPES) {
167+
it(`${type} + multiple:true — array TS type AND a JSON column in both migrations`, () => {
168+
const declared = tsInterfaceType(`multi_${type}`);
169+
expect(declared, `os generate types must give a flagged ${type} an array type`)
170+
.toMatch(/\[\]$/);
171+
172+
expect(
173+
sqlColumn(`multi_${type}`),
174+
`os generate migration --format sql gave a flagged ${type} a scalar column while ` +
175+
'the platform stores it as JSON (driver-sql createColumn decides `multiple` before ' +
176+
'the type switch), and os generate types called it an array',
177+
).toBe('JSONB');
178+
179+
expect(
180+
tsColumn(`multi_${type}`),
181+
`os generate migration (typescript) gave a flagged ${type} a scalar column while ` +
182+
'the platform stores it as JSON, and os generate types called it an array',
183+
).toBe(`table.jsonb('multi_${type}')`);
184+
});
185+
}
186+
187+
it('the flag decides before the type — a type outside MULTI_CAPABLE_TYPES too', () => {
188+
// Stated as its own assertion because it is the one place this pin departs
189+
// from the spec's value predicate on purpose. `text` is not multi-capable
190+
// under `isMultiValueField`, and the driver gives it a JSON column anyway.
191+
expect(MULTI_CAPABLE_TYPES.has('text')).toBe(false);
192+
expect(sqlColumn('multi_text')).toBe('JSONB');
193+
expect(tsColumn('multi_text')).toBe("table.jsonb('multi_text')");
194+
});
195+
196+
it('nullability still comes from `required`, not from the flag', () => {
197+
const out = generateMigrationSql({
198+
objects: { probe: { name: 'probe', fields: { tags_req: { type: 'lookup', multiple: true, required: true } } } },
199+
});
200+
expect(out).toContain('"tags_req" JSONB NOT NULL');
201+
const ts = generateMigrationTs({
202+
objects: { probe: { name: 'probe', fields: { tags_req: { type: 'lookup', multiple: true, required: true } } } },
203+
});
204+
expect(ts).toContain("table.jsonb('tags_req').notNullable();");
205+
});
206+
207+
// ── The authority, read where it lives ──────────────────────────────────
208+
//
209+
// Source-read rather than imported: `createColumn` is `protected` and needs a
210+
// knex table builder, so driving it would mean a live driver and a built
211+
// `dist`. What has to be pinned is the SHAPE of its decision — flag first,
212+
// type second — and that is legible in the source. If the driver ever moves
213+
// this rule, these fail and whoever moved it re-derives the generators.
214+
215+
it('driver-sql `createColumn` still decides `multiple` BEFORE the type switch', () => {
216+
const source = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'sql-driver.ts'), 'utf8');
217+
// Non-vacuity: the file was really read, and the two landmarks really found.
218+
expect(source.length).toBeGreaterThan(10_000);
219+
const start = source.indexOf('protected createColumn(');
220+
expect(start, 'createColumn moved or was renamed in driver-sql').toBeGreaterThan(0);
221+
const switchAt = source.indexOf('switch (type)', start);
222+
expect(switchAt, 'the per-type switch in createColumn moved').toBeGreaterThan(start);
223+
224+
const preSwitch = source.slice(start, switchAt);
225+
expect(
226+
preSwitch,
227+
'driver-sql no longer short-circuits on `field.multiple` before its per-type switch. ' +
228+
'That short-circuit is the authority this pin and the CLI migration generators mirror ' +
229+
'(#14829) — re-derive both sides before changing it.',
230+
).toMatch(/if \(field\.multiple\)/);
231+
expect(preSwitch).toMatch(/this\.jsonColumn\(/);
232+
});
233+
234+
it('driver-sql `fieldHasColumn` still answers the flag before the type', () => {
235+
const source = fs.readFileSync(path.join(DRIVER_SQL_SRC, 'schema-drift.ts'), 'utf8');
236+
expect(source.length).toBeGreaterThan(10_000);
237+
const start = source.indexOf('export function fieldHasColumn(');
238+
expect(start, 'fieldHasColumn moved or was renamed in driver-sql').toBeGreaterThan(0);
239+
expect(source.slice(start, start + 300)).toMatch(/if \(field\?\.multiple\) return true;/);
240+
});
241+
242+
// ── SCOPE FENCE for #14828 — NOT an endorsement ─────────────────────────
243+
//
244+
// These five scalar answers disagree with what the platform stores and were
245+
// left byte-for-byte on purpose (correcting them changes DDL already-generated
246+
// apps have RUN). #14828 owns them. They are asserted here so that changing
247+
// one is a deliberate edit to this block rather than a side effect of a card
248+
// about the `multiple` flag — #14828 must update it when it corrects them.
249+
it('#14828 fence — the five disputed SCALAR answers are untouched by this card', () => {
250+
expect(sqlColumn('single_lookup')).toBe('VARCHAR(36)');
251+
expect(tsColumn('single_lookup')).toBe("table.uuid('single_lookup')");
252+
253+
const other = generateMigrationSql({
254+
objects: { probe: { name: 'probe', fields: {
255+
a: { type: 'autonumber' }, f: { type: 'formula' },
256+
m: { type: 'multiselect' }, v: { type: 'vector' }, d: { type: 'master_detail' },
257+
} } },
258+
});
259+
expect(other).toContain('"a" SERIAL');
260+
expect(other).toContain('"f" TEXT');
261+
expect(other).toContain('"m" TEXT');
262+
expect(other).toContain('"v" VECTOR');
263+
expect(other).toContain('"d" VARCHAR(36)');
264+
});
265+
});

0 commit comments

Comments
 (0)