Skip to content

Commit bff011c

Browse files
os-zhuangclaude
andauthored
refactor(spec,objectql,driver-sql): share the autonumber counter readback as spec's inverse of renderAutonumber (#6560) (#7247)
`packages/spec` gains `readAutonumberCounter(value, prefix, suffix)`, the declared inverse of `renderAutonumber`, and the two hand-written copies of that readback — one in the ObjectQL engine, one in the SQL driver — are replaced by calls to it. Pure refactor: zero behaviour change. PR #6553 (#6468) taught both seeding paths to locate a counter by the format's declared prefix/suffix pair, but landed that reading as two independent copies of the same four lines. That is the exact shape of the defect they were fixing: two hand-written readings of one composition rule had already drifted into two different wrong answers over one dataset, so the record-number band depended on which driver ran. Only the ANCHORED rule — the one both sides must apply identically — moved. The unanchored case stays per-side because the two sides deliberately differ there (engine: last digit run; driver: every digit concatenated), and #6553 preserved both byte-for-byte; spec returns undefined rather than claim an agreement that does not exist. The packages/runtime cross-side parity suite is unmodified and passes as-is, which is the evidence the semantics moved without changing. Per the maintainer's ruling on #6560 (2026-08-08 ×2, re-confirmed 2026-08-10): non-authorable export, no Zod, no new vocabulary — api-surface bookkeeping plus two call-site swaps. Claude-Session: https://claude.ai/code/session_01DqCMGGYsvFxMJSU4PMSPo3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 596b462 commit bff011c

8 files changed

Lines changed: 291 additions & 60 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/objectql': patch
4+
'@objectstack/driver-sql': patch
5+
---
6+
7+
refactor(spec,objectql,driver-sql): the autonumber counter readback is one shared pure function, beside the renderer it inverses (#6560)
8+
9+
`packages/spec` gains `readAutonumberCounter(value, prefix, suffix)`, the declared
10+
inverse of `renderAutonumber`, and both consumers call it instead of holding their
11+
own copy.
12+
13+
**Why the inverse belongs where the composition already lives.** `renderAutonumber`
14+
composes `prefix + zero-padded(seq) + suffix` and its file header states it is
15+
"shared by the ObjectQL engine and the SQL driver so both paths render identical
16+
record numbers". PR #6553 (#6468) had to teach both seeding paths to read a counter
17+
back out of a stored value — and landed that reading as two hand-written copies of
18+
the same four lines, one in `packages/objectql`, one in
19+
`packages/drivers/driver-sql`. That is the exact shape of the defect those copies
20+
were fixing: two independent readings of one composition rule had already drifted
21+
into two *different* wrong answers over one dataset (`001-2026` read as `2026` by
22+
the engine and `12026` by the driver), so the record-number band a tenant received
23+
depended on which driver happened to run, and numbers burned that way cannot be
24+
reclaimed. A cross-package `runtime` parity test caught the drift once; it does not
25+
force a future single-side edit to run it.
26+
27+
**What moved and what did not.** Only the ANCHORED rule — the one both sides must
28+
apply identically — is now spec's: the counter is the digit run at the start of
29+
what follows the rendered `prefix`, after stripping the rendered `suffix` when the
30+
value carries it (stripped when it matches, never required to match, since one
31+
counter spans the years a dynamic suffix renders). Out-of-scope values read as
32+
`undefined`, which also gives the SQL driver back its JS-side re-check of a `LIKE`
33+
that matched looser than `startsWith` under a case-insensitive collation.
34+
35+
The UNANCHORED case (neither affix declared) stays per-side, because the two sides
36+
deliberately differ there and #6553 preserved both byte-for-byte: the engine reads
37+
the last digit run, the driver concatenates every digit. Spec returns `undefined`
38+
rather than pick one — a shared contract that claimed an agreement which does not
39+
exist would be worse than no shared contract. Each side documents its own fallback
40+
at its own call site.
41+
42+
**Zero behaviour change.** Every call site keeps its existing guards and its
43+
existing result for every input; the `packages/runtime` cross-side parity suite that
44+
pins the two seeding paths against each other is unmodified and passes as-is, which
45+
is the evidence the semantics moved without changing. Per the maintainer's ruling on
46+
#6560 (2026-08-08, twice, re-confirmed 2026-08-10): a non-authorable export — no
47+
Zod, no new vocabulary, no acceptance-face change — so this is api-surface
48+
bookkeeping plus two call-site swaps.

packages/drivers/driver-sql/src/sql-driver.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99

1010
import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data';
11-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data';
11+
import { parseAutonumberFormat, renderAutonumber, readAutonumberCounter, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data';
1212
// The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's
1313
// "the protocol has no such function" refusal cannot drift from what
1414
// `AggregationNodeSchema.function` actually admits.
@@ -3618,9 +3618,16 @@ export class SqlDriver implements IDataDriver {
36183618
*
36193619
* - **Either declared ⇒ ANCHORED**: the counter is the digit run at the
36203620
* START of what follows the prefix, after removing the declared suffix
3621-
* when the row carries it.
3621+
* when the row carries it. That reading is not this driver's to hold: it
3622+
* is spec's `readAutonumberCounter`, the declared inverse of
3623+
* `renderAutonumber`, called here and by the engine's seeding scan so one
3624+
* edit moves both sides (#6560 — the ruling that retired the two
3625+
* hand-written copies PR #6553 left).
36223626
* - **Neither declared ⇒ UNANCHORED**: the legacy reading (every digit in
3623-
* the value, concatenated) is kept byte-for-byte.
3627+
* the value, concatenated) is kept byte-for-byte, and stays HERE. The
3628+
* engine's legacy reading of the same case differs on purpose (it takes
3629+
* the last digit run), so there is nothing shared to hoist — spec answers
3630+
* `undefined` for an unanchored slot rather than pick one of the two.
36243631
*
36253632
* ## Why the suffix is NOT pushed into the LIKE
36263633
*
@@ -3654,15 +3661,14 @@ export class SqlDriver implements IDataDriver {
36543661
if (typeof v !== 'string') continue;
36553662
let n: number;
36563663
if (anchored) {
3657-
// A driver-side `LIKE` can match looser than JS `startsWith` (collation,
3658-
// case-insensitive columns); re-check so another scope cannot inflate
3659-
// this counter, mirroring the engine's own JS-side re-check.
3660-
if (prefix && !v.startsWith(prefix)) continue;
3661-
let core = v.slice(prefix.length);
3662-
if (suffix && core.endsWith(suffix)) core = core.slice(0, core.length - suffix.length);
3663-
const head = core.match(/^\d+/);
3664-
if (!head) continue;
3665-
n = parseInt(head[0], 10);
3664+
// Spec's inverse of `renderAutonumber` (#6560). It also re-checks the
3665+
// prefix in JS: a driver-side `LIKE` can match looser than `startsWith`
3666+
// (collation, case-insensitive columns), and a row from another scope
3667+
// must not inflate this counter — it reads as `undefined`, same as a
3668+
// row carrying no counter at all.
3669+
const read = readAutonumberCounter(v, prefix, suffix);
3670+
if (read === undefined) continue;
3671+
n = read;
36663672
} else {
36673673
// Unanchored: `prefix` is '' here, so this is the whole value.
36683674
n = parseInt(v.replace(/[^0-9]/g, ''), 10);

packages/objectql/src/engine-autonumber-resync.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
*
2727
* - **Adopt** (`adoptExplicitAutonumber`): lift the counter from the value an
2828
* exempt writer supplied, read by #6468's anchoring rules — the SAME reading
29-
* the seeding scan performs, now shared as `readAutonumberCounter`. Costs one
29+
* the seeding scan performs, shared as `readStoredAutonumberCounter` (whose
30+
* anchored half is spec's `readAutonumberCounter`, #6560). Costs one
3031
* string parse and NO query, and makes the warm counter converge on what a
3132
* cold re-seed of the same store would answer.
3233
* - **Re-seed on collision** (`createWithAutonumberResync`): drop the stale

packages/objectql/src/engine.ts

Lines changed: 44 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
2121
// engine is what `metadata-protocol.validateData` returns, so letting the two
2222
// drift would put a translation layer between a verdict and its contract.
2323
import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api';
24-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data';
24+
import { parseAutonumberFormat, renderAutonumber, readAutonumberCounter, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data';
2525
// [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1)
2626
// runs, so `FilterArray` has exactly one lowering in the product.
2727
import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data';
@@ -352,39 +352,30 @@ interface IssuedAutonumber {
352352
* (#6806) so both readings can never drift apart — a divergence here is a
353353
* duplicate record number, which is the harm the whole family is about.
354354
*
355-
* `prefix` and `suffix` are `renderAutonumber`'s own declared output; this
356-
* function derives no format understanding of its own (see `seedAutonumber`'s
357-
* "Locating the counter inside a stored value" section for the full rationale):
355+
* The ANCHORED reading itself is NOT this package's (#6560): it is the inverse
356+
* of `renderAutonumber`'s composition, so it lives beside it as spec's
357+
* {@link readAutonumberCounter}, which the SQL driver's `scanMaxNumericTail`
358+
* calls over the same two strings. This function adds only the piece the two
359+
* sides genuinely do not share:
358360
*
359-
* - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit
360-
* run at the START of what follows the prefix, after removing the declared
361-
* suffix when this row carries it. The suffix is stripped when it matches,
362-
* never required to match — a dynamic suffix renders differently per row
363-
* while the counter scope is the rendered PREFIX, so those rows share this
364-
* counter and must still be read.
365-
* - **Neither declared ⇒ UNANCHORED**: the legacy reading — the LAST digit
366-
* run of the whole value.
361+
* - **Either `prefix` or `suffix` declared ⇒ ANCHORED**: spec answers, and its
362+
* TSDoc carries the rationale (counter after the prefix, suffix stripped
363+
* when it matches and never required to match, out-of-scope values read as
364+
* `undefined`).
365+
* - **Neither declared ⇒ UNANCHORED**: this engine's own legacy reading — the
366+
* LAST digit run of the whole value — kept byte-for-byte by PR #6553. The
367+
* SQL driver's legacy reading of the same case is deliberately DIFFERENT (it
368+
* concatenates every digit), which is exactly why spec refuses to answer for
369+
* an unanchored slot instead of picking one of the two.
367370
*
368-
* A value outside the scope (it does not carry the rendered prefix) reads as
369-
* `undefined`: it belongs to another counter and must not lift this one.
370-
*
371-
* Both branches use linear `/\d+/` forms — a backtracking lookahead here is a
372-
* polynomial-ReDoS sink on stored values full of zeros (CodeQL
371+
* The unanchored branch uses the linear `/\d+/g` — a backtracking lookahead here
372+
* is a polynomial-ReDoS sink on stored values full of zeros (CodeQL
373373
* js/polynomial-redos).
374374
*/
375-
function readAutonumberCounter(value: string, prefix: string, suffix: string): number | undefined {
376-
if (prefix && !value.startsWith(prefix)) return undefined;
377-
const anchored = prefix !== '' || suffix !== '';
378-
let digits: string | undefined;
379-
if (anchored) {
380-
let core = value.slice(prefix.length);
381-
if (suffix && core.endsWith(suffix)) core = core.slice(0, core.length - suffix.length);
382-
const head = core.match(/^\d+/);
383-
digits = head ? head[0] : undefined;
384-
} else {
385-
const runs = value.match(/\d+/g);
386-
digits = runs ? runs[runs.length - 1] : undefined;
387-
}
375+
function readStoredAutonumberCounter(value: string, prefix: string, suffix: string): number | undefined {
376+
if (prefix !== '' || suffix !== '') return readAutonumberCounter(value, prefix, suffix);
377+
const runs = value.match(/\d+/g);
378+
const digits = runs ? runs[runs.length - 1] : undefined;
388379
if (!digits) return undefined;
389380
const n = parseInt(digits, 10);
390381
return Number.isFinite(n) ? n : undefined;
@@ -2698,9 +2689,9 @@ export class ObjectQL implements IObjectQLEngine {
26982689
* (#6806) — the free half of the resync, and the one that closes the shape
26992690
* #5495's PROBE1 measured on a warm database.
27002691
*
2701-
* The value is parsed with {@link readAutonumberCounter}, i.e. by exactly the
2702-
* anchoring rules #6468 gave the seeding scan, against the prefix/suffix this
2703-
* record's own format renders. So adopting is the same reading a cold re-seed
2692+
* The value is parsed with {@link readStoredAutonumberCounter}, i.e. by
2693+
* exactly the anchoring rules #6468 gave the seeding scan, against the
2694+
* prefix/suffix this record's own format renders. So adopting is the same reading a cold re-seed
27042695
* would perform over the same row — which is the invariant to hold on to: a
27052696
* warm counter must answer what a restart would answer.
27062697
*
@@ -2717,8 +2708,8 @@ export class ObjectQL implements IObjectQLEngine {
27172708
* persisted, so the first generating insert's own scan reads it.
27182709
* - **Never lowers.** A counter that has already issued numbers must not go
27192710
* back over them; the max is a floor that only rises.
2720-
* - **Only within this record's scope.** `readAutonumberCounter` returns
2721-
* `undefined` for a value that does not carry the rendered prefix, so a
2711+
* - **Only within this record's scope.** The reading returns `undefined`
2712+
* for a value that does not carry the rendered prefix, so a
27222713
* historical import into last month's date scope cannot lift THIS
27232714
* month's counter. Its own scope's counter is left untouched, which is
27242715
* harmless: a scope is derived from the write instant, so a past scope's
@@ -2753,7 +2744,7 @@ export class ObjectQL implements IObjectQLEngine {
27532744
const counterKey = `${object}.${field}.${probe.scope}`;
27542745
const seeded = this.autonumberCounters.get(counterKey);
27552746
if (seeded == null) return; // not seeded yet — the first seed scan will read this row
2756-
const supplied = readAutonumberCounter(value, probe.prefix, probe.suffix);
2747+
const supplied = readStoredAutonumberCounter(value, probe.prefix, probe.suffix);
27572748
if (supplied == null || supplied <= seeded) return;
27582749
this.autonumberCounters.set(counterKey, supplied);
27592750
this.logger.debug('Autonumber counter lifted to an externally supplied value', {
@@ -2933,15 +2924,19 @@ export class ObjectQL implements IObjectQLEngine {
29332924
*
29342925
* - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit
29352926
* run at the START of what follows the prefix, after removing the declared
2936-
* suffix when this row carries it.
2927+
* suffix when this row carries it. That half is spec's
2928+
* `readAutonumberCounter` — the declared inverse of `renderAutonumber`,
2929+
* which the SQL driver's scan calls too, so one edit moves both sides
2930+
* (#6560).
29372931
* - **Neither declared ⇒ UNANCHORED**: the legacy reading is kept — the LAST
29382932
* digit run of the whole value. A format with no `{0..0}` slot renders a
29392933
* bare trailing counter, and values predating any format have no anchor to
2940-
* read from, so this stays exactly as it was.
2934+
* read from, so this stays exactly as it was. The SQL driver's legacy
2935+
* reading of this case differs on purpose, which is why it stays per-side.
29412936
*
2942-
* That reading is {@link readAutonumberCounter}, module-level rather than
2943-
* inline, because #6806's resync must read an exempt writer's supplied value
2944-
* by exactly these rules.
2937+
* The two together are {@link readStoredAutonumberCounter}, module-level
2938+
* rather than inline, because #6806's resync must read an exempt writer's
2939+
* supplied value by exactly these rules.
29452940
*
29462941
* The suffix is *stripped when it matches*, never *required* to match: a
29472942
* dynamic suffix renders differently per row (`{000}-{YYYY}` is `-2025` on
@@ -3017,12 +3012,14 @@ export class ObjectQL implements IObjectQLEngine {
30173012
for (const r of page) {
30183013
const v = r?.[field];
30193014
if (v == null) continue;
3020-
// The reading itself lives in `readAutonumberCounter` (the section
3021-
// above describes it) because #6806's adopt-on-exempt-write resync
3022-
// must read a supplied value by the SAME rules this scan reads a
3023-
// stored one — two copies of it would drift into two different
3024-
// answers for one row, which is a duplicate record number.
3025-
const counter = readAutonumberCounter(String(v), prefix, suffix);
3015+
// The reading itself lives in `readStoredAutonumberCounter` (the
3016+
// section above describes it) because #6806's adopt-on-exempt-write
3017+
// resync must read a supplied value by the SAME rules this scan reads
3018+
// a stored one — two copies of it would drift into two different
3019+
// answers for one row, which is a duplicate record number. Its
3020+
// anchored half is spec's `readAutonumberCounter`, the one the SQL
3021+
// driver's own scan calls (#6560).
3022+
const counter = readStoredAutonumberCounter(String(v), prefix, suffix);
30263023
if (counter != null) max = Math.max(max, counter);
30273024
}
30283025
}

packages/spec/api-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@
654654
"parseFilterAST (function)",
655655
"percentScaleOf (function)",
656656
"provisionPrimary (function)",
657+
"readAutonumberCounter (function)",
657658
"reduceFilterKeyVerdict (function)",
658659
"reduceFilterVerdict (function)",
659660
"referenceTargetOf (function)",

packages/spec/export-origins/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@
654654
"parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)",
655655
"percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)",
656656
"provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)",
657+
"readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)",
657658
"reduceFilterKeyVerdict": "src/data/filter-verdict.ts#reduceFilterKeyVerdict (function)",
658659
"reduceFilterVerdict": "src/data/filter-verdict.ts#reduceFilterVerdict (function)",
659660
"referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)",

0 commit comments

Comments
 (0)