|
| 1 | +--- |
| 2 | +"@objectstack/cli": patch |
| 3 | +--- |
| 4 | + |
| 5 | +fix(cli): `os generate migration` emits the field-level unique index the driver creates (#16317) |
| 6 | + |
| 7 | +## What was wrong |
| 8 | + |
| 9 | +Both migration formats emitted the table and none of the object's declared |
| 10 | +uniqueness. Measured on live PostgreSQL 16.13 — one object driven through all |
| 11 | +three producers into three schemas, `pg_indexes` read back per schema: |
| 12 | + |
| 13 | +```ts |
| 14 | +{ name: 'probe', fields: { keyed_unique: { type: 'text', unique: true, maxLength: 100 } } } |
| 15 | +``` |
| 16 | + |
| 17 | +| producer | before | after | |
| 18 | +|:--|:--|:--| |
| 19 | +| `driver-sql` via `initObjects` | `probe_pkey`, `uniq_probe_keyed_unique` | unchanged | |
| 20 | +| `--format sql` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | |
| 21 | +| `--format ts` | `probe_pkey` | `probe_pkey`, **`uniq_probe_keyed_unique`** | |
| 22 | + |
| 23 | +Two rows with the same `keyed_unique` value were refused by the platform's table |
| 24 | +(`23505 ... violates unique constraint "uniq_probe_keyed_unique"`) and accepted |
| 25 | +by both generated ones, with nothing reporting it: a scaffold that creates the |
| 26 | +table for an object silently dropped a uniqueness guarantee the object declares. |
| 27 | +After the change the duplicate is refused by all three, each naming the same |
| 28 | +constraint. |
| 29 | + |
| 30 | +The key set was not missing — it was already computed here to size the keyed |
| 31 | +text family's columns; only the index it implies was never emitted. |
| 32 | + |
| 33 | +## What it does now |
| 34 | + |
| 35 | +- **`--format sql`** emits an inline `CONSTRAINT "<name>" UNIQUE (<columns>)`. |
| 36 | + That is what knex's `table.unique(columns, { indexName })` — the driver's own |
| 37 | + call — compiles to on PostgreSQL, so a generated table and a platform-created |
| 38 | + one agree in `pg_constraint` as well as in `pg_indexes`; and it stays inside |
| 39 | + the statement's `IF NOT EXISTS`, which a following `ALTER TABLE ... ADD |
| 40 | + CONSTRAINT` has no spelling for. |
| 41 | +- **`--format ts`** emits that knex call itself, `indexName` included — which is |
| 42 | + what makes the driver recognise the constraint as already present on its first |
| 43 | + boot against a generated table, instead of adding a second one under its own |
| 44 | + name and then reporting the generated one as an orphan to drop. |
| 45 | +- Names come from a transcription of `driver-sql`'s `buildIndexName`, pinned |
| 46 | + against the driver's own export (a CLI production module may not statically |
| 47 | + value-import a driver package). |
| 48 | + |
| 49 | +## What it deliberately still does not emit — and now says so |
| 50 | + |
| 51 | +Both formats print a `NOT EMITTED:` line naming the index, its key parts and the |
| 52 | +reason, instead of dropping it silently: |
| 53 | + |
| 54 | +- the **organization-scoped composite** (`unique: true` / `'organization'` on an |
| 55 | + object with an organization column), whose key part is |
| 56 | + `COALESCE(<organization column>, '__global__')`. Emitting the bare composite |
| 57 | + instead would be worse than emitting nothing: under SQL's NULL-distinct |
| 58 | + `UNIQUE` it constrains no row that has no organization, which on a |
| 59 | + single-tenant deployment is every row. |
| 60 | +- an index over a column no field materialises (a virtual `formula` field) — |
| 61 | + the same skip the driver performs, where the driver logs a warning. |
| 62 | + |
| 63 | +Object-level `indexes[]` remains unemitted by both formats; it is normalized by |
| 64 | +a different driver-side rule and is not covered by this change. |
0 commit comments