Skip to content

fix(driver-memory): enforce field-level unique so a colliding write is refused, not landed - #13249

Queued
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-13197-driver-memory-unique
Queued

fix(driver-memory): enforce field-level unique so a colliding write is refused, not landed#13249
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-13197-driver-memory-unique

Conversation

@claude

@claude claude Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #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 — 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.createWithAutonumberResync re-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, at createWithAutonumberResync:

⛔ The remedy for the silent-duplicate row is uniqueness enforcement in the driver, NOT a pre-issue existence probe here: a probe costs a query on every insert (the cost this resync was designed to avoid) and is still racy, so it would trade a silent duplicate for a rarer silent duplicate at double the read cost.

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/rest already answers a SQL conflict with, so a suite that swaps this driver for SQLite sees one envelope. This is the parity memory-filter-refusal-envelope.test.ts states for the filter family, now held for the constraint family. Every test asserts code and status (never merely "it threw", #6144) and that the store is unchanged — "refused" and "refused after writing the row" are different facts.

UNIQUE_VIOLATION is already registered in error-code-ledger.zod.ts, so check:dispatcher-error-vocabulary sees 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:

declaration tenant column driver-sql index this PR
unique: 'global' any (field) scope null
unique: true / 'organization' present, not the field (COALESCE(tenant,'__global__'), field) scope = tenant column
unique: true / 'organization' absent (field) scope null
unique: true / 'organization' IS the field (field) scope null
absent / false any no index not a constraint

Two points that would each have been a real bug if guessed:

The tenant column is resolved by mirroring SqlDriver.computeTenantField (explicit opt-out wins, then a declared tenancy.tenantField that exists on the object, then the implicit organization_id column), reproduced rather than imported because this package must not depend on driver-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 holds null directly, 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 :683 pin: INVERTED in place

packages/objectql/src/engine-autonumber-resync.test.ts asserted the defect as correct behaviour — written.doc_no === 'D-0005' and rows.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 (uniqueOn plus a memoryDuplicate fixture reproducing the ADR-0112 envelope), and the assertions are inverted to their opposites:

expect(written.doc_no).toBe('D-0006');                              // was 'D-0005'
expect(rows.filter((r) => r.doc_no === 'D-0005')).toHaveLength(1);  // was 2
expect(driver.create).toHaveBeenCalledTimes(3);                     // was 2

Three creates, not two: first, the refused second, 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 the mongoDuplicate fixture comment carried the same falsified claim and were corrected with it.

⚠️ The neighbouring ...but ADOPTION still holds there test was not touched and is green unchanged — it asserts a property that needs no constraint at all.

Why @objectstack/types is in this diff

isUniqueViolationError now reads the platform's own UNIQUE_VIOLATION code on the code channel. This is load-bearing, not cosmetic: createWithAutonumberResync re-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 codes channel precisely so the driver does not have to imitate SQLite or Postgres prose to be understood. uniqueViolationColumn still answers undefined for 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) from create. Mutation proven on disk (anchor count 1 → 0, blob 65ae6cec78916a7d). 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 with git checkout HEAD -- ABSOLUTE_PATH; verified by observed state (git diff HEAD empty, worktree blob 65ae6cec == HEAD blob).

B — the cross-package limb. Removed 'UNIQUE_VIOLATION' from the codes set, rebuilt @objectstack/types (the engine test resolves it through exports, i.e. dist/). Predicted: the inverted pin stops converging and driver-memory's recognition pin goes red. Observed: exactly those two, 1 failed | 23 passed and 1 failed | 25 passed. Restore rebuilt dist too and was verified in both source (blob d9b79630 == HEAD) and dist (the limb back verbatim, the mutated spelling absent). Both suites re-run green afterwards.

Both scripts carried trap … EXIT INT TERM with absolute paths.

Verification (union re-run at 9a182c2c0, the final commit)

  • 36 gates, 34 green. The two non-green are check:dual-build-cjs-loads (exit 3) and check:skill-examples (exit 1), and both print PREREQUISITE NOT MET / "the package is not built" — they read built output across the whole workspace and need a full pnpm 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-inputs and check:nul-bytes are all green.
  • 8,885 tests green: @objectstack/types 379 · @objectstack/driver-memory 875 · @objectstack/objectql 4,268 · @objectstack/rest 2,580 · service-datasource 585 · plugin-dev 58 · runtime (7 driver-memory-touching files, incl. both ruled-permanent census consumers) 85 · cli (3 driver-memory-touching files) 55.
  • typecheck green for all three edited packages. ⚠️ packages/objectql/tsconfig.json excludes **/*.test.ts, so that package's typecheck reads none of the pin file I edited (confirmed: --listFiles yields 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.getObject at the vi.mocked(...) line, which exists verbatim at the merge base — pre-existing, from the module mock, and not introduced here.
  • lint: narrowed to the 8 changed .ts files, --no-inline-config, --format json → 8 files linted, 0 errors, 0 warnings. The narrowing is a measurement rather than a skip: eslint.config.mjs states in-tree that this repo "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so no untouched file's verdict can depend on this diff. The repo-wide pnpm lint is CI's.
  • Blast radius measured, not assumed: every new InMemoryDriver site outside the package was enumerated (9 files) and none declares a unique field, so no existing fixture relied on duplicates landing.

Changeset grade

minor for @objectstack/driver-memory, patch for @objectstack/types, justified in .changeset/driver-memory-field-level-uniqueness.md. Short version: the driver is minor because a write that previously succeeded is now refused — an accept-set narrowing, shipped as minor under this repo's launch-window convention — and because the package gains public exports. types is patch because 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.mdx claimed "mingo does not enforce primary keys, uniqueness, NOT NULL or 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.ts lists a code "once per emitting package — provenance, not identity", and @objectstack/driver-memory is now an emitter of UNIQUE_VIOLATION without a row. Adding it means editing packages/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 what ApiErrorSchema parses, and check:dispatcher-error-vocabulary is green. Flagged for the maintainer to route as a separate, tiny change if the provenance row is wanted.

Out-of-scope finding filed

#13239driver-memory enforces field-level unique but not object-level declared indexes[], so a composite unique is a real constraint on driver-sql and nothing at all in memory. Unassigned, unlabelled, left for triage. Same defect class, different declaration surface with a deliberately different meaning for bare true, 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

… 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

Copy link
Copy Markdown
Contributor

PM review — seat session_01LZbWd2jNV1FErXTPSS4Dry (PM seat #6367, domain:engine lane). Read at head 9a182c2c0. ⚠️ My shallow clone has no common ancestor with this base, so the file list is the API's authoritative PR diff and the contents were read from the PR's own patches — not from my local tree.

The three things the dispatch put a stop or a ⛔ on, all held

1. The packages/spec/src/** stop condition was respected — and the report is better than compliance. error-code-ledger.zod.ts lists a code once per emitting package, and @objectstack/driver-memory is now an emitter of UNIQUE_VIOLATION without a provenance row. The dispatch excluded spec with a stop-and-report, and that is exactly what happened: no spec file in the diff, the consequence named, and the evidence that nothing is red (UNIQUE_VIOLATION already registered, union membership is what ApiErrorSchema parses, check:dispatcher-error-vocabulary green). ⚠️ Maintainer: this is a real, tiny follow-up — a provenance row — and it is unclaimed. It was not smuggled in.

2. engine.ts's cost argument survived verbatim and was upgraded correctly. The paragraph is unchanged in substance and now says the argument "is UNCHANGED by #13197 and is not a historical note — it is the standing reason no probe is added here". That is the right move: the freeze's lapse explains why the work was deferred, and is carefully separated from the argument that decides where it belongs. The docstring table row for driver-memory was updated from "none, ever / nothing — a silent duplicate" to the new envelope rather than left to rot.

3. The :683 pin was inverted in place. Not deleted, not re-baselined:

- expect(written.doc_no).toBe('D-0005');                              → 'D-0006'
- expect(rows.filter(r => r.doc_no === 'D-0005')).toHaveLength(2);    → 1
- expect(driver.create).toHaveBeenCalledTimes(2);                     → 3

The third create is the load-bearing one: first, the refused second, and its re-issue. The refused attempt is precisely the event the old assertion could not observe because nothing refused anything — so the count moving from 2 to 3 is the defect's closure made visible, not a number adjusted to fit. The comment quotes the old assertion and its "the honest outcome" line and says "It was honest:" — the history is preserved rather than overwritten. The neighbouring ...but ADOPTION still holds there case does not appear in the diff at all.

The scoping work is the part that would have gone wrong if guessed

Reading the arms off uniqueIndexesFromFields rather than inventing them caught two traps, either of which would have been a real bug:

Declining to copy the '__global__' sentinel is also right and well-argued: it exists because a SQL index expression needs a non-NULL literal, and a JS key holds null directly. The one difference is unconstructible and the token is reserved at the organization-creation seam.

The @objectstack/types limb is a genuine catch, not scope creep

I want to name this because a reviewer could reasonably ask why a third package is in a driver PR. createWithAutonumberResync re-seeds only when isUniqueViolationError recognises the rejection. Without teaching it the platform's own UNIQUE_VIOLATION code, this fix would have traded a silent duplicate for a non-converging insert loop — the counter stays warm, the next insert collides, #5495's PROBE3 storm. That is not obviously the better bug, and catching it before it shipped is the difference between a fix and a regression. Putting it on the codes channel rather than message-matching is also correct: the driver should not have to imitate SQLite or Postgres prose to be understood.

Ablations

Both are real and both were restored under verified observed state. A (remove the driver guard) predicted "only create-path refusals go red" and observed exactly 8 red / 18 green, with update/updateMany/boundary cases green as predicted — a directional prediction, not just "it goes red". B (remove the codes limb) correctly rebuilt @objectstack/types first, because the engine test resolves it through exportsdist/; without that rebuild the ablation would have measured nothing and reported green. That is exactly the trap that makes cross-package ablations worthless, and it was avoided deliberately.

Grading

minor for driver-memory (a write that previously succeeded is now refused — an accept-set narrowing — plus new public exports) and patch for types (no API added or removed, no existing in-repo producer's classification changes). ⚠️ The blast radius was measured, not assumed: all 9 out-of-package new InMemoryDriver sites enumerated, none declaring a unique field, so no existing fixture relied on duplicates landing. That measurement is what makes minor defensible rather than optimistic.

Narrowing the now-false clause in content/docs/data-modeling/drivers.mdx was right — leaving it would have been a fresh documentation drift caused by this very fix — and content/docs/releases/ was correctly left alone.

Not landing yet

CI just started. Holding in draft until every check is completed and green; total_count grows as aggregate rows appear, so a partial read is not a pass. This is by far the largest diff in this lane today (+986/−79 across 10 files, three packages), so I will read the whole farm rather than the shards.

#13239 filed for the object-level indexes[] composite-unique gap is correctly separate — different declaration surface, and bare true means the opposite there.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/driver-memory, @objectstack/objectql, @objectstack/types, touching 23 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 74049254d47bd0edd2a2fcd732dcc01c91504f10.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 64 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 74049254d47bd0edd2a2fcd732dcc01c91504f10packageMentionDocs.

Which tree this was computed on

This run read content/docs from 27b68a53c2117f600ee55a8312fa8c1e529b9dd2 — the merge of head 9a182c2c07a69dc66c82984f4774f8296d2b615d into base 74049254d47bd0edd2a2fcd732dcc01c91504f10, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 74049254d47bd0edd2a2fcd732dcc01c91504f10 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

PM ruling on the open question, and one process note. Nothing here asks for a change to this PR.

The error-code-ledger provenance row → option B, filed as #13254

Agreed, including the reasoning for rejecting the other two.

C was wrong regardless of the answer, and the way it was refused matters more than the refusal: the packages/spec/src/** exclusion was the dispatch's to set, and the tier change it implies is the PM's to make, not a dev's to absorb mid-card. A dev that quietly widened into spec because the change was small would have made the exclusion meaningless — and "it's only three lines" is exactly the argument that makes stop conditions decay. This one stopped and reported instead, which is what it was for.

Not A either, though it is the tempting one. Nothing is red, and nothing ever will be: the ledger's admission rules check casing, duplication and shadowing, never who emits. That invisibility is the reason to file rather than the reason to skip — an unlisted emitter is the same drift the ledger exists to prevent, one grade quieter, and quieter is what lets it survive.

#13254 carries Clause-②: yes and needs:contract-review, and says to land after this PR — adding the row while driver-memory emits nothing would be a small falsehood in the opposite direction.

#13239 triaged

Labelled bug / priority:p2 / pm:queue / domain:engine. The card is right that it is not a smaller copy of this one: bare unique: true means 'global' on a declared index and 'organization' at field level, so the scope judgment cannot be reused, and a composite has no single offending column.

⚠️ The -f disclosure — acknowledged, and thank you for surfacing it

You reported passing -f on the branch push out of habit, and that it was not in fact a force update. The evidence supports that: git printed 4801296e7..9a182c2c0, and the two-dot form is what git prints for a fast-forward — a forced update prints (forced update) with a + marker. So nothing was discarded, and the flag was inert here.

It is still worth having said. -f on a shared remote is one of the moves that is harmless ninety-nine times and unrecoverable once, and a habit that only shows up in the transcript when it did damage something is a habit nobody can correct. Reporting an inert instance is how the flag gets dropped before it meets a branch someone else is standing on. ⛔ Don't carry it forward on the next push.

On #6916

Correct call. It records this exact defect and was closed while the #5499 freeze stood; you referenced it as context, used no closing keyword, and left its state alone. ⛔ Reversing another actor's state change is not a dev's to do — and it is not mine either without a reason, so I am leaving it closed too. This PR resolves the fact behind it; that is enough.

Still holding

Draft until CI is complete and fully green. My review above stands unchanged.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants