Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/generated-migration-character-column-widths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/cli": minor
---

`os generate migration` now emits the character column `driver-sql` actually creates, in both the TypeScript and the SQL format.

A `text` field took `VARCHAR(255)` from both generators while the platform creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. Enumerating the whole character-column family found the same disagreement in eight more places: the SQL format gave `url` and `phone` and `color` widths nothing on the platform has (2048, 50 and 7 against the platform's 255), and neither format read a field's declared `maxLength` at all, so a `maxLength: 400` email was `varchar(400)` on the platform and `varchar(255)` in the migration generated for it.

All of them now follow the platform's own three answers: the text family is unbounded unless the object KEYS the column — a field declared `unique`, or one an object-level `indexes[]` entry lists, takes `varchar(maxLength)` up to the 768-character key-part ceiling, exactly as the platform builds it, and stays unbounded above that ceiling or with no declared bound, where the declared bound is enforced at the write seam instead — the string family takes its declared `maxLength` verbatim in both directions, and TEXT rather than a clamp when it exceeds what a `varchar` can express, and the remaining string-valued types keep the default width and ignore a declaration, because their stored value is an option code or another row's id rather than the declared string.

The keyed half was measured after the rest: `{ type: 'text', unique: true, maxLength: 100 }` built `varchar(100)` on the platform and `text` in both generated tables, so a 300-character value the platform REFUSES was accepted by every generated table — the same disagreement as the headline row, pointing the other way.

This scopes to PostgreSQL, which is the only dialect `os generate migration --format sql` claims.
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,13 @@ describe('the builtin id column both migration generators emit (#15040)', () =>
expect(sql.indexOf('"id"')).toBeLessThan(sql.indexOf('"title"'));
const ts = generateMigrationTs(CONFIG as Record<string, unknown>);
expect(ts).toContain("await db.schema.createTable('account'");
expect(ts.indexOf("table.string('id')")).toBeLessThan(ts.indexOf("table.string('title')"));
// #16091 — matched on the field NAME rather than on its column method. The
// assertion is about ORDER (the primary key comes first), and a reader keyed
// to `table.string` silently became `indexOf(…) === -1` the moment `title`,
// a `text` field, moved to `table.text` — which reads as a passing
// "less than" only until you notice what it is less than.
expect(ts.indexOf("table.string('id')")).toBeLessThan(ts.indexOf("('title')"));
expect(ts.indexOf("('title')"), 'the title column vanished from the output').toBeGreaterThan(0);
// Each generator carries exactly ONE hardcoded id line — the shape that let
// these two disagree with the driver in the first place, and the reason a
// fix to one of them can silently leave the other behind. Counted over the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,18 @@ describe('#14828 — the SQL answers are the platform’s, not this file’s inv
expect(tsInterfaceType('number')).toBe('number');
// The driver's own answer for the headline member, read where it lives.
expect(createColumnArm('autonumber')).toContain('table.string(name)');
expect(sqlColumn('autonumber')).toBe(sqlColumn('text'));
// #16091 — compared against `lookup`, not against `text`. Both were
// `VARCHAR(255)` when this line was written, which made `text` a usable
// stand-in for "the driver's default string column"; it is not one any
// more. `createColumn` gives `text` its text-family arm (an unbounded TEXT
// for every unkeyed column) and gives `lookup` the same bare
// `table.string(name)` it gives `autonumber` — asserted here, from the
// driver, so the comparator cannot silently become a different question again.
expect(createColumnArm('lookup')).toContain('table.string(name)');
expect(sqlColumn('autonumber')).toBe(sqlColumn('lookup'));
// Anti-vacuity: the comparator is a real, DIFFERENT answer from the
// text family's, so this equality is a measurement rather than a tautology.
expect(sqlColumn('autonumber')).not.toBe(sqlColumn('text'));
expect(tsColumn('autonumber')).toBe("table.string('f_autonumber')");
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,14 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces',
// control cannot be satisfied by one column shape for everything either.
expect(sqlColumn('single_lookup')).toBe('VARCHAR(255)');
expect(tsColumn('single_lookup')).toBe("table.string('single_lookup')");
expect(sqlColumn('single_text')).toBe('VARCHAR(255)');
expect(tsColumn('single_text')).toBe("table.string('single_text')");
// #16091 — `text` is an unbounded TEXT column now, which is what
// `createColumn`'s text-family arm builds for every unkeyed column. The
// control is unweakened by that for exactly the reason the `lookup` note
// above gives: what it discriminates is scalar-vs-JSON, and TEXT is scalar.
expect(sqlColumn('single_text')).toBe('TEXT');
expect(tsColumn('single_text')).toBe("table.text('single_text')");
// …and it still discriminates: the scalar answer is not the JSON one.
expect(sqlColumn('single_text')).not.toBe(sqlColumn('multi_text'));
expect(sqlColumn('single_file')).toBe('VARCHAR(2048)');
expect(tsInterfaceType('single_lookup')).toBe('string');
});
Expand Down Expand Up @@ -285,8 +291,8 @@ describe('#14829 — `multiple: true` is one answer across all three surfaces',
// this card does NOT touch is present in both outputs. Without it, "does
// not contain" would pass on an empty string.
expect(other).toContain('CREATE TABLE IF NOT EXISTS "probe" (');
expect(other).toContain('"t" VARCHAR(255)');
expect(otherTs).toContain("table.string('t')");
expect(other).toContain('"t" TEXT');
expect(otherTs).toContain("table.text('t')");

// A RENDERED string (prefix + counter + suffix), never an integer sequence.
expect(other).toContain('"a" VARCHAR(255)');
Expand Down
Loading
Loading