Uh oh!
There was an error while loading. Please reload this page.
fix(driver-memory): enforce field-level unique so a colliding write is refused, not landed - #13249
Conversation
… is refused, not landed (#13197) `InMemoryDriver` enforced no uniqueness at all: `create` was a `table.push()` and `syncSchema` allocated an array, so a `unique: true` field was declared-and-not-enforced and a colliding write LANDED, with a read returning both rows. The motivating instance is the worst-shaped one. `createWithAutonumberResync` re-seeds and re-issues a record number only when the STORE rejects it, so on a store that rejected nothing the branch was unreachable and an out-of-process autonumber duplicated a business identifier with no error anywhere. The remedy's location was already ruled in-tree at that method — uniqueness in the driver, never a pre-issue existence probe in the engine — and this is that remedy. - `memory-unique-constraint.ts` is the single judgment point: constraint derivation, the NULL-distinct bucket key, and the refusal. - The refusal carries the ADR-0112 envelope the SQL family answers a conflict with: `code: 'UNIQUE_VIOLATION'`, `status: 409`, no driver prefix. It is checked before the row is written, and `updateMany` checks the whole batch before mutating any of it. - Scoping is `driver-sql`'s `uniqueIndexesFromFields` (ADR-0120 D1/D3), reproduced arm for arm: `'global'` platform-wide; bare `true` and `'organization'` per-organization; both degrade to a single column with no tenant column; a `unique` on the tenant column itself stays single-column. NULL values stay NULL-DISTINCT. - `@objectstack/types`: `isUniqueViolationError` reads the platform's own `UNIQUE_VIOLATION` code. Load-bearing — an unrecognised refusal would leave the counter warm and turn a silent duplicate into a non-converging insert loop. - The `engine-autonumber-resync` pin that asserted the DEFECT is INVERTED in place, not deleted or re-baselined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
os-zhuang
commented
Aug 29, 2026
PM review — seat The three things the dispatch put a stop or a ⛔ on, all held1. The 2. 3. The The third The scoping work is the part that would have gone wrong if guessedReading the arms off
Declining to copy the The |
📓 Docs Drift CheckThis PR changes 3 package(s): 29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 27b68a53c2117f600ee55a8312fa8c1e529b9dd2 && git checkout 27b68a53c2117f600ee55a8312fa8c1e529b9dd2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 74049254d47bd0edd2a2fcd732dcc01c91504f10 9a182c2c07a69dc66c82984f4774f8296d2b615d && git checkout -B drift-repro 74049254d47bd0edd2a2fcd732dcc01c91504f10 && git merge --no-ff 9a182c2c07a69dc66c82984f4774f8296d2b615d
node scripts/docs-audit/affected-docs.mjs --json 74049254d47bd0edd2a2fcd732dcc01c91504f10
|
os-zhuang
commented
Aug 29, 2026
PM ruling on the open question, and one process note. Nothing here asks for a change to this PR. The |
Uh oh!
There was an error while loading. Please reload this page.
…s across 12 areas Five-angle sweep (console UI / spec enums / routes+runtime / built-in apps / docs claims) at a286411, per docs/qa/platform-checklist/SWEEP.md. This commit carries 8 of 9 writer waves (platform-core follows): - records-forms: fieldGroups.visibleWhen (3-angle hit), field unique enforcement (#13249), delete-behavior matrix, record clone contract, trackHistory, import transform matrix; severity + feed-filter revisions - api-backend: aggregate matrix, formula stdlib matrix, apiMethods verb gate; route-ledger-parity rewritten to the real 11-ledger universe (stale) - access-security: OS_PLATFORM_OWNER_EMAIL anchor (#13146), /auth/me aggregation parity, read auditing, /s/:token landing page - approvals: act-token door, approver-resolution matrix, status mirror, inbox keyboard flow; READ_BACK_FAILED clause (#13181); quorum re-priced - identity-auth: first-run owner bootstrap, self-signup gate (invite_only default posture), email verification, org switch; oauth consent UNBLOCKED - integration-system: connector auth kinds, federation UI; two false-finding stale items fixed (datasource ledger, checkOnBoot #13149); redeliver clause - automation/studio/dashboards: D16 nav clause inverted (#12457), metadata diagnostics sweep, report schedule dispatch (fail-closed posture at head) - ai/cli/search: console AI-surface gating (K2 probes), autoMigrate policy, command-palette navigation coverage.json: new items mapped into 19 kinds; still 0 waivers. Validator green: 249 items, all self-checks pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VnWcVdzYnitiLiWFbrMfc5
Fixes#13197
InMemoryDriverenforced no uniqueness at all.createwas atable.push()andsyncSchemaallocated an array, so aunique: truefield was declared-and-not-enforced — the ADR-0078 / Prime-Directive-#10 shape the platform refuses everywhere else. A colliding write did not fail; it landed, and a read returned both rows.The card's motivating instance is the worst-shaped one.
ObjectQL.createWithAutonumberResyncre-seeds the counter and re-issues a record number when the store rejects it as a duplicate, so on a store that rejected nothing the whole branch was unreachable: an autonumber allocated out of process duplicated an existing business identifier with no error anywhere. Nothing in the new code knows what an autonumber is — the defect was that the driver constrained nothing, and the autonumber case is a consequence.The direction was already ruled in-tree
packages/objectql/src/engine.ts, atcreateWithAutonumberResync:That argument is left intact and is now stated as standing rather than historical — it is the reason no probe is added, and the reason the fix went to the driver. The
#5495-shaped engine-side re-check stays excluded: #5495 works on the SQL side only by riding a rejection the store issues, and driver-memory issued none.The refusal envelope
code: 'UNIQUE_VIOLATION',status: 409, no[driver-memory]prefix — the wire identity@objectstack/restalready answers a SQL conflict with, so a suite that swaps this driver for SQLite sees one envelope. This is the paritymemory-filter-refusal-envelope.test.tsstates for the filter family, now held for the constraint family. Every test assertscodeandstatus(never merely "it threw", #6144) and that the store is unchanged — "refused" and "refused after writing the row" are different facts.UNIQUE_VIOLATIONis already registered inerror-code-ledger.zod.ts, socheck:dispatcher-error-vocabularysees no unregistered code-stamping site (verified, green). See "One thing left undone" below for the provenance row I deliberately did not add.Scoping: measured off driver-sql, not invented
Read off
uniqueIndexesFromFields(packages/drivers/driver-sql/src/schema-drift.ts, ADR-0120 D1/D3) and reproduced arm for arm:unique: 'global'(field)nullunique: true/'organization'(COALESCE(tenant,'__global__'), field)unique: true/'organization'(field)nullunique: true/'organization'(field)nullfalseTwo points that would each have been a real bug if guessed:
trueis the positional spelling of'organization'at FIELD level, not of'global'(the driver-sql: 表级{ fields, unique: true }在 tenant-scoped 对象上落成平台级 UNIQUE —— normalizeDeclaredIndex 不补租户列(#4698 实例 2 移交) #4986 trap — on a declaredindexes[]entry it means the opposite). Reading it as global would make two organizations' identical record numbers collide on a constraint neither can see, which is the cross-tenant existence oracle ADR-0120 D1 exists to remove.UNIQUE. Folding them together would refuse the second row of every table with an optional unique column — a refusaldriver-sqldoes not issue, i.e. a fresh divergence introduced by the fix for a divergence.The tenant column is resolved by mirroring
SqlDriver.computeTenantField(explicit opt-out wins, then a declaredtenancy.tenantFieldthat exists on the object, then the implicitorganization_idcolumn), reproduced rather than imported because this package must not depend ondriver-sql. Each arm is pinned.The ADR-0120 D3
'__global__'sentinel is deliberately not copied. That token exists because a SQL index expression needs a non-NULL literal to fold NULL-organization rows onto; a JavaScript key holdsnulldirectly, so the same bucket is reached with no token and no cross-package constant. The one behavioural difference is unconstructible: a row whose organization id literally equals'__global__'would share SQL's platform bucket and gets its own here, and that token is reserved at the organization-creation seam.The
:683pin: INVERTED in placepackages/objectql/src/engine-autonumber-resync.test.tsasserted the defect as correct behaviour —written.doc_no === 'D-0005'androws.filter(…D-0005)).toHaveLength(2)— over a comment calling two rows carrying one business identifier "the honest outcome".It is not deleted and not re-baselined. The rig is now configured the way driver-memory actually behaves (
uniqueOnplus amemoryDuplicatefixture reproducing the ADR-0112 envelope), and the assertions are inverted to their opposites:Three creates, not two:
first, the refusedsecond, and its re-issue — the refused attempt is precisely what the old assertion could not observe, because nothing refused it. The test comment records what the pin used to say, what refuses the duplicate now, and that the engine-side probe stayed rejected for the cost reason above. The file header's five-driver table and themongoDuplicatefixture comment carried the same falsified claim and were corrected with it....but ADOPTION still holds theretest was not touched and is green unchanged — it asserts a property that needs no constraint at all.Why
@objectstack/typesis in this diffisUniqueViolationErrornow reads the platform's ownUNIQUE_VIOLATIONcode on thecodechannel. This is load-bearing, not cosmetic:createWithAutonumberResyncre-seeds only when that predicate says the rejection was a conflict, so a refusal it does not recognise propagates with the counter still warm and the next insert collides too — #5495's PROBE3 storm. Without this limb the fix would trade a silent duplicate for a non-converging insert loop, which is not obviously the better bug.It is a tautology rather than a widened heuristic (the code already means this condition), so it carries none of the false-positive risk the message limbs are rationed against, and it goes on the
codeschannel precisely so the driver does not have to imitate SQLite or Postgres prose to be understood.uniqueViolationColumnstill answersundefinedfor this refusal — the documented fallback MongoDB already relies on, and the reason an unnamed column counts as attributable.Reverse verification (two ablations, both restored and re-measured)
A — the driver guard. Removed
this.assertUnique(object, newRecord)fromcreate. Mutation proven on disk (anchor count 1 → 0, blob65ae6cec→78916a7d). Predicted direction: only the create-path refusals go red. Observed: exactly 8 red, 18 green — the update / updateMany / boundary cases stayed green, as predicted. Restored withgit checkout HEAD -- ABSOLUTE_PATH; verified by observed state (git diff HEADempty, worktree blob65ae6cec== HEAD blob).B — the cross-package limb. Removed
'UNIQUE_VIOLATION'from thecodesset, rebuilt@objectstack/types(the engine test resolves it throughexports, i.e.dist/). Predicted: the inverted pin stops converging and driver-memory's recognition pin goes red. Observed: exactly those two,1 failed | 23 passedand1 failed | 25 passed. Restore rebuiltdisttoo and was verified in both source (blobd9b79630== HEAD) anddist(the limb back verbatim, the mutated spelling absent). Both suites re-run green afterwards.Both scripts carried
trap … EXIT INT TERMwith absolute paths.Verification (union re-run at
9a182c2c0, the final commit)check:dual-build-cjs-loads(exit 3) andcheck:skill-examples(exit 1), and both printPREREQUISITE NOT MET/ "the package is not built" — they read built output across the whole workspace and need a fullpnpm build. Nothing was measured, in either direction; they are CI's on a fully built tree.check:driver-conformance,check:driver-memory-census,check:dispatcher-error-vocabulary,check:error-code-casing,check:error-status-conformance,check:cross-package-test-inputsandcheck:nul-bytesare all green.@objectstack/types379 ·@objectstack/driver-memory875 ·@objectstack/objectql4,268 ·@objectstack/rest2,580 ·service-datasource585 ·plugin-dev58 · runtime (7 driver-memory-touching files, incl. bothruled-permanentcensus consumers) 85 · cli (3 driver-memory-touching files) 55.packages/objectql/tsconfig.jsonexcludes**/*.test.ts, so that package'stypecheckreads none of the pin file I edited (confirmed:--listFilesyields 0 hits for it; driver-memory and types yield 1 each for theirs). Measured separately with a scratch config that includes tests: the file produces exactly one error,TS2339 SchemaRegistry.getObjectat thevi.mocked(...)line, which exists verbatim at the merge base — pre-existing, from the module mock, and not introduced here..tsfiles,--no-inline-config,--format json→ 8 files linted, 0 errors, 0 warnings. The narrowing is a measurement rather than a skip:eslint.config.mjsstates in-tree that this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file", so no untouched file's verdict can depend on this diff. The repo-widepnpm lintis CI's.new InMemoryDriversite outside the package was enumerated (9 files) and none declares auniquefield, so no existing fixture relied on duplicates landing.Changeset grade
minorfor@objectstack/driver-memory,patchfor@objectstack/types, justified in.changeset/driver-memory-field-level-uniqueness.md. Short version: the driver isminorbecause a write that previously succeeded is now refused — an accept-set narrowing, shipped asminorunder this repo's launch-window convention — and because the package gains public exports.typesispatchbecause no API is added or removed and no existing in-repo producer's classification changes:@objectstack/rest's response body is the only other site carrying that string and it is downstream of the predicate.Scope
Deliberately not widened into object-level declared
indexes[](composite uniques), primary keys,$exists,$notContains/$nin, or filtered aggregation. Row-level tenant isolation is untouched: this scopes a uniqueness key the way ADR-0120 does, and the driver still refuses to boot multi-tenant (#6915).content/docs/data-modeling/drivers.mdxclaimed "mingo does not enforce primary keys, uniqueness,NOT NULLor column types". That sentence is now false in one clause, so it was narrowed rather than left to drift. No release notes were touched.One thing left undone, on purpose
error-code-ledger.zod.tslists a code "once per emitting package — provenance, not identity", and@objectstack/driver-memoryis now an emitter ofUNIQUE_VIOLATIONwithout a row. Adding it means editingpackages/spec/src/**, which this card's dispatch excluded with a stop-and-report instruction. No gate is red: the code is registered, union membership is whatApiErrorSchemaparses, andcheck:dispatcher-error-vocabularyis green. Flagged for the maintainer to route as a separate, tiny change if the provenance row is wanted.Out-of-scope finding filed
#13239 —
driver-memoryenforces field-leveluniquebut not object-level declaredindexes[], so a composite unique is a real constraint ondriver-sqland nothing at all in memory. Unassigned, unlabelled, left for triage. Same defect class, different declaration surface with a deliberately different meaning for baretrue, so it is not a smaller copy of this card.Historical note, no action implied: #6916 recorded this same defect earlier and was closed while the #5499 freeze stood. It is referenced here only as context and is not addressed by this PR.
Generated by Claude Code
Generated by Claude Code