|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec): an `autonumber` field is `unique: 'organization'` by default; explicit `unique: false` opts out (#13894) |
| 6 | + |
| 7 | +**BREAKING** emitted-shape change on `FieldSchema` (the accept set is unchanged), |
| 8 | +shipped as `minor` under the repo's launch-window convention for breaking changes. |
| 9 | + |
| 10 | +An auto-number is a business identifier — a contract number, a quote number, a |
| 11 | +case number — and an identifier that may repeat is not one. Yet the platform |
| 12 | +only ever materialized a unique index where the author had written `unique` |
| 13 | +by hand: of hotcrm's nine auto-numbered identifiers, exactly one |
| 14 | +(`crm_case.case_number`, `unique: true`) carried the tenant-composite unique |
| 15 | +index, and the other eight could mint the same number twice (measured: |
| 16 | +objectstack#12394 re-issued `ACC-000009`). Maintainer ruling 2026-08-31 |
| 17 | +(hotcrm#1301): the default flips. |
| 18 | + |
| 19 | +- An `autonumber` field that **omits** `unique` now parses to |
| 20 | + `unique: 'organization'` — one holder per organization, materialized by the |
| 21 | + drivers exactly as `case_number`'s hand-written declaration was: the NULL-safe |
| 22 | + tenant-composite index `(COALESCE(organization_id, '__global__'), <field>)` on an |
| 23 | + organization-scoped object, a plain unique index on an object with no |
| 24 | + organization key. |
| 25 | +- Every **other** field type keeps `unique: false` as its default, at the same |
| 26 | + key position — parse output for non-autonumber fields is byte-identical. |
| 27 | +- Every **authored** spelling (`true`, `'organization'`, `'global'`, `false`) |
| 28 | + parses exactly as before, on every type. |
| 29 | +- The default is materialized at parse time (the `.overwrite()` tail of |
| 30 | + `FieldSchema`, the type-conditional precedent `deleteBehavior` set), because |
| 31 | + the drivers read the parsed `unique` value-only; the published JSON Schema |
| 32 | + therefore no longer carries `default: false` on `Field.unique` — the |
| 33 | + description states the rule, and the authorable-defaults ratchet records the |
| 34 | + move as `data/Field:unique = false → (none)`. |
| 35 | + |
| 36 | +**Opting out.** Write `unique: false` explicitly on the autonumber field. That |
| 37 | +is the whole opt-out surface — no second key. It is legitimate only for a |
| 38 | +display-only sequence that nothing uses to identify the record; note that the |
| 39 | +platform's duplicate scan (`os migrate duplicates`) keeps treating every |
| 40 | +autonumber field as an identifier regardless. |
| 41 | + |
| 42 | +**Migration — what an operator with existing duplicates sees.** A table that |
| 43 | +already holds duplicate auto-numbers cannot take the index. On SQLite/Postgres/ |
| 44 | +MySQL the SQL driver does not fail the boot and does not skip silently: it logs |
| 45 | +on the `error` channel — |
| 46 | + |
| 47 | +``` |
| 48 | +[sql-driver] cannot create NULL-safe unique index 'uniq_crm_quote_organization_id_quote_number' on "crm_quote" — existing rows violate it (duplicates the previous NULL-distinct index admitted, #5030). The constraint 'organization_id, quote_number' is NOT enforced until the data is deduplicated: run "os migrate plan" for the conflicting rows (ADR-0120 D4). |
| 49 | +``` |
| 50 | + |
| 51 | +— and the same boot's drift pass names the conflicting key groups with their |
| 52 | +row counts: |
| 53 | + |
| 54 | +``` |
| 55 | +[schema-drift] crm_quote: cannot create 'uniq_crm_quote_organization_id_quote_number' as UNIQUE (COALESCE(organization_id, '__global__'), quote_number) — existing rows already violate the NULL-safe unique constraint (duplicates the old index wrongly admitted, #5030): (organization_id="__global__", quote_number="QUO-00009") × 2 rows; (organization_id="org_x", quote_number="QUO-00010") × 2 rows. The op is BLOCKED: apply re-probes and refuses, and the existing index stays in place (ADR-0120 D4). Deduplicate the listed rows, then re-run "os migrate plan". |
| 56 | +``` |
| 57 | + |
| 58 | +`os migrate plan` reports the same blocked `create_index` with the same groups |
| 59 | +until the rows are deduplicated; `os migrate duplicates` lists the holder row |
| 60 | +ids of any value minted across organization partitions (the seed/API split). |
| 61 | +Deduplicate — which duplicate keeps its number is a business decision — then |
| 62 | +re-run `os migrate plan` / restart, and the index materializes. An object with |
| 63 | +`tenancy.enabled: false` takes a plain unique index instead, and there the |
| 64 | +driver raises the database's own unique-violation error at boot (it names the |
| 65 | +index, not the rows) — run `os migrate duplicates` / a `GROUP BY <field> HAVING |
| 66 | +COUNT(*) > 1` to find them. |
| 67 | + |
| 68 | +Two landed defects change shape on purpose under the default: a counter that |
| 69 | +re-issues a number after a burned reservation (#12394) and two counters minting |
| 70 | +for one object (#8686) used to produce a *silent* duplicate; they now produce a |
| 71 | +loud unique-violation refusal at the write. |
| 72 | + |
| 73 | +<!-- adr-0087: registered autonumber-default-unique-organization --> |
0 commit comments