Skip to content

Commit 742a6a5

Browse files
os-zhuangclaude
andauthored
fix(objectql): resync the engine fallback autonumber counter — adopt exempt writers' numbers, re-seed on collision (#6806) (#6907)
* fix(objectql): resync the fallback autonumber counter (adopt + collision re-seed) (#6806) The engine fallback autonumber path seeded `object.field.<scope>` once and then incremented purely in memory, so it drifted below the store's real max in two ways it could never recover from. - Adopt an exempt writer's supplied record number into the counter (isSystem seed replay / preserveAudit import / beforeInsert hook stamp). Free: one string parse, no extra query. Read by #6468's anchoring rules, now shared with the seeding scan as `readAutonumberCounter` so the two cannot drift. - Re-seed and re-issue on a unique violation attributable to an autonumber the engine issued, bounded to 3 attempts, then refuse with `code: 'ERR_AUTONUMBER_COLLISION'` carrying the driver error as `cause`. The predicates come from `@objectstack/types` (#6250 / #6544), never a dialect word-list of the engine's own. Batch inserts drop the stale counter but are never re-issued (bulkCreate may be partially applied). #6114's read-failure discrimination is unchanged and now also covers the re-seed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QW3F6hGmFkf1RpwGBthDo * test(objectql): pin the storage-dependence of the collision half and the batch outcome (#6806) driver-memory enforces no uniqueness at all (its create is a table.push(), #4065), so the collision branch is unreachable there and a duplicate lands silently. Named and pinned rather than left implied (PD #10); adoption is the half that covers that driver. Also pins what an author gets on a batch collision: the driver's own error, never ERR_AUTONUMBER_COLLISION, with the counter dropped so the caller's retry converges. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QW3F6hGmFkf1RpwGBthDo * docs(objectql): name the drivers behind the storage-dependent collision half (#6806) Measured `supports.autonumber` across all five in-repo drivers rather than asserting "the storage layer": only driver-memory (`supports = {}`) and driver-mongodb (bit absent) take the engine fallback path; driver-sql declares `autonumber: true` and driver-sqlite-wasm / driver-turso inherit it via `extends SqlDriver`. Of the two, only driver-mongodb can raise a unique violation, so the collision retry protects essentially one backend. Anchored to the reading the repo already ruled and gates — scripts/driver-memory-census.ledger.json's `ruled-permanent` disposition for autonumber-seed-cross-side-parity.integration.test.ts ("InMemoryDriver declares `supports = {}`, so the ENGINE's autonumber seeding owns the counter") — rather than authoring a second answer to who owns the counter (#6832's shape). The test rig is a hand-rolled fake driver and imports no driver package, so check:driver-memory-census sees no unledgered arrival; re-run to confirm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QW3F6hGmFkf1RpwGBthDo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 183b4c4 commit 742a6a5

3 files changed

Lines changed: 1252 additions & 33 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): resync the engine's fallback autonumber counter instead of seeding it once (#6806)
6+
7+
On the engine's **fallback** autonumber path — the one serving drivers that do
8+
NOT declare `supports.autonumber` (driver-memory, driver-mongodb); SQL drivers
9+
own a persistent sequence and are untouched — `applyAutonumbers` seeded
10+
`object.field.<scope>` from the store **once** and then incremented purely in
11+
memory. That is only the truth while the engine is the sole writer of the field,
12+
and it never is. Two holes, closed from the two ends that can see them.
13+
14+
**1. An exempt writer's record number now lifts the counter (free).** `isSystem`
15+
seed replay, a `preserveAudit` historical import and a `beforeInsert` hook stamp
16+
all reach the "respect an explicit value" branch — #5503's strip exempts exactly
17+
those three — so each persisted a number the counter never saw. The counter kept
18+
issuing from the one-time seed, *below* the store's real max, and every number
19+
up to that max was a duplicate business identifier. The supplied value is now
20+
parsed with the same #6468 anchoring rules the seeding scan uses (extracted as
21+
one shared reader, so the two readings cannot drift) and the counter is lifted to
22+
it. Cost: one string parse, **no extra query** — a warm counter now converges on
23+
what a cold re-seed of the same store would answer.
24+
25+
Adoption deliberately never throws (an exempt write was accepted before and
26+
still is), never lowers a counter, never seeds an *unseeded* counter (that would
27+
skip the seeding scan and answer from one row), and ignores a value outside the
28+
record's own counter scope (a historical import into a past date scope cannot
29+
burn today's band).
30+
31+
**2. A collision now re-seeds and re-issues instead of burning numbers.** A
32+
counter sitting below the real max — because a writer *outside* this process
33+
took numbers the engine could not observe — collided on every insert until it
34+
walked past that max one number at a time; each failed create surfaced the
35+
driver's raw error *and* advanced the counter, so it never converged on its own.
36+
A unique-constraint failure attributable to an autonumber the engine issued now
37+
drops the counter, re-seeds from the store and re-issues, bounded to 3 attempts;
38+
past that the write fails with `code: 'ERR_AUTONUMBER_COLLISION'` carrying the
39+
driver's error as `cause`, rather than the raw driver error. A conflict on a
40+
different column, and any non-unique failure, are rethrown untouched.
41+
42+
**This half is storage-dependent, and the docs name the drivers.** It is
43+
triggered by the store rejecting the duplicate, so it reaches only drivers that
44+
take this fallback path *and* enforce uniqueness. Measured across all five
45+
in-repo drivers: only **driver-memory** (`supports = {}`) and **driver-mongodb**
46+
(bit absent) take the path at all — driver-sql declares `autonumber: true`, and
47+
driver-sqlite-wasm and driver-turso inherit it via `extends SqlDriver`. Of those
48+
two, only driver-mongodb can raise a violation (a single-field unique index, when
49+
the field declares `unique`); **driver-memory never does** — its `create` is a
50+
`table.push()` storing no constraints at all — so there a duplicate still lands
51+
silently and this branch is unreachable.
52+
53+
So the collision retry protects essentially one backend, and that is now stated
54+
in those terms rather than as "the storage layer". It is not a new claim: it is
55+
the reading already ruled and gated in `scripts/driver-memory-census.ledger.json`
56+
for `autonumber-seed-cross-side-parity.integration.test.ts` — "InMemoryDriver
57+
declares `supports = {}`, so the ENGINE's autonumber seeding owns the counter. No
58+
SQL backend can stand in". The silent-duplicate outcome is pinned rather than
59+
left implied, and driver-memory is covered by the adoption half above, which
60+
waits for no rejection. Enforcing uniqueness in the driver is the remedy for the
61+
remaining case and is not attempted here.
62+
63+
A **batch** insert drops the stale counter but is never re-issued (`bulkCreate`
64+
may be partially applied, so re-writing it could duplicate the rows that did
65+
land). `insert(object, rows[])` and `insertMany` therefore reject with the
66+
**driver's own** duplicate-key error — never `ERR_AUTONUMBER_COLLISION`, which is
67+
the single-row identity for "re-issued and still refused" — and the guarantee a
68+
batch does get is that the next write re-seeds, so a caller's retry converges.
69+
70+
The unique-violation questions are asked of `@objectstack/types`'
71+
`isUniqueViolationError` / `uniqueViolationColumn` (#6250 / #6544), never a
72+
dialect word-list of the engine's own. #6114's read-failure discrimination is
73+
unchanged and now also covers the re-seed: a missing table still seeds from 0,
74+
every other read failure still propagates and writes nothing.
75+
76+
The counter stays **global** (not tenant-partitioned) — that remains parked per
77+
#5495's disposition.

0 commit comments

Comments
 (0)