From 3c8d14b73f36fb52e14c62c6b69ac8b310ce8254 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:43:04 +0000 Subject: [PATCH] refactor(spec,objectql,driver-sql): share the autonumber counter readback as spec's inverse of renderAutonumber (#6560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DqCMGGYsvFxMJSU4PMSPo3 --- .../autonumber-counter-readback-shared.md | 48 ++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 30 +++-- .../src/engine-autonumber-resync.test.ts | 3 +- packages/objectql/src/engine.ts | 91 ++++++++------- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + .../spec/src/data/autonumber-format.test.ts | 105 ++++++++++++++++++ packages/spec/src/data/autonumber-format.ts | 72 ++++++++++++ 8 files changed, 291 insertions(+), 60 deletions(-) create mode 100644 .changeset/autonumber-counter-readback-shared.md diff --git a/.changeset/autonumber-counter-readback-shared.md b/.changeset/autonumber-counter-readback-shared.md new file mode 100644 index 0000000000..eeac43ed06 --- /dev/null +++ b/.changeset/autonumber-counter-readback-shared.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': patch +'@objectstack/objectql': patch +'@objectstack/driver-sql': patch +--- + +refactor(spec,objectql,driver-sql): the autonumber counter readback is one shared pure function, beside the renderer it inverses (#6560) + +`packages/spec` gains `readAutonumberCounter(value, prefix, suffix)`, the declared +inverse of `renderAutonumber`, and both consumers call it instead of holding their +own copy. + +**Why the inverse belongs where the composition already lives.** `renderAutonumber` +composes `prefix + zero-padded(seq) + suffix` and its file header states it is +"shared by the ObjectQL engine and the SQL driver so both paths render identical +record numbers". PR #6553 (#6468) had to teach both seeding paths to read a counter +back out of a stored value — and landed that reading as two hand-written copies of +the same four lines, one in `packages/objectql`, one in +`packages/drivers/driver-sql`. That is the exact shape of the defect those copies +were fixing: two independent readings of one composition rule had already drifted +into two *different* wrong answers over one dataset (`001-2026` read as `2026` by +the engine and `12026` by the driver), so the record-number band a tenant received +depended on which driver happened to run, and numbers burned that way cannot be +reclaimed. A cross-package `runtime` parity test caught the drift once; it does not +force a future single-side edit to run it. + +**What moved and what did not.** Only the ANCHORED rule — the one both sides must +apply identically — is now spec's: the counter is the digit run at the start of +what follows the rendered `prefix`, after stripping the rendered `suffix` when the +value carries it (stripped when it matches, never required to match, since one +counter spans the years a dynamic suffix renders). Out-of-scope values read as +`undefined`, which also gives the SQL driver back its JS-side re-check of a `LIKE` +that matched looser than `startsWith` under a case-insensitive collation. + +The UNANCHORED case (neither affix declared) stays per-side, because the two sides +deliberately differ there and #6553 preserved both byte-for-byte: the engine reads +the last digit run, the driver concatenates every digit. Spec returns `undefined` +rather than pick one — a shared contract that claimed an agreement which does not +exist would be worse than no shared contract. Each side documents its own fallback +at its own call site. + +**Zero behaviour change.** Every call site keeps its existing guards and its +existing result for every input; the `packages/runtime` cross-side parity suite that +pins the two seeding paths against each other 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, twice, re-confirmed 2026-08-10): a non-authorable export — no +Zod, no new vocabulary, no acceptance-face change — so this is api-surface +bookkeeping plus two call-site swaps. diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index bf61beda86..a72ef68f07 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -8,7 +8,7 @@ */ import type { DriverOptions, FilterCondition, SchemaMode } from '@objectstack/spec/data'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, readAutonumberCounter, missingFieldValues, isTenancyDisabled, type AutonumberToken } from '@objectstack/spec/data'; // The DECLARED aggregate vocabulary (#5907). Read from the spec so this driver's // "the protocol has no such function" refusal cannot drift from what // `AggregationNodeSchema.function` actually admits. @@ -3618,9 +3618,16 @@ export class SqlDriver implements IDataDriver { * * - **Either declared ⇒ ANCHORED**: the counter is the digit run at the * START of what follows the prefix, after removing the declared suffix - * when the row carries it. + * when the row carries it. That reading is not this driver's to hold: it + * is spec's `readAutonumberCounter`, the declared inverse of + * `renderAutonumber`, called here and by the engine's seeding scan so one + * edit moves both sides (#6560 — the ruling that retired the two + * hand-written copies PR #6553 left). * - **Neither declared ⇒ UNANCHORED**: the legacy reading (every digit in - * the value, concatenated) is kept byte-for-byte. + * the value, concatenated) is kept byte-for-byte, and stays HERE. The + * engine's legacy reading of the same case differs on purpose (it takes + * the last digit run), so there is nothing shared to hoist — spec answers + * `undefined` for an unanchored slot rather than pick one of the two. * * ## Why the suffix is NOT pushed into the LIKE * @@ -3654,15 +3661,14 @@ export class SqlDriver implements IDataDriver { if (typeof v !== 'string') continue; let n: number; if (anchored) { - // A driver-side `LIKE` can match looser than JS `startsWith` (collation, - // case-insensitive columns); re-check so another scope cannot inflate - // this counter, mirroring the engine's own JS-side re-check. - if (prefix && !v.startsWith(prefix)) continue; - let core = v.slice(prefix.length); - if (suffix && core.endsWith(suffix)) core = core.slice(0, core.length - suffix.length); - const head = core.match(/^\d+/); - if (!head) continue; - n = parseInt(head[0], 10); + // Spec's inverse of `renderAutonumber` (#6560). It also re-checks the + // prefix in JS: a driver-side `LIKE` can match looser than `startsWith` + // (collation, case-insensitive columns), and a row from another scope + // must not inflate this counter — it reads as `undefined`, same as a + // row carrying no counter at all. + const read = readAutonumberCounter(v, prefix, suffix); + if (read === undefined) continue; + n = read; } else { // Unanchored: `prefix` is '' here, so this is the whole value. n = parseInt(v.replace(/[^0-9]/g, ''), 10); diff --git a/packages/objectql/src/engine-autonumber-resync.test.ts b/packages/objectql/src/engine-autonumber-resync.test.ts index 7293f4f319..4a26f9c246 100644 --- a/packages/objectql/src/engine-autonumber-resync.test.ts +++ b/packages/objectql/src/engine-autonumber-resync.test.ts @@ -26,7 +26,8 @@ * * - **Adopt** (`adoptExplicitAutonumber`): lift the counter from the value an * exempt writer supplied, read by #6468's anchoring rules — the SAME reading - * the seeding scan performs, now shared as `readAutonumberCounter`. Costs one + * the seeding scan performs, shared as `readStoredAutonumberCounter` (whose + * anchored half is spec's `readAutonumberCounter`, #6560). Costs one * string parse and NO query, and makes the warm counter converge on what a * cold re-seed of the same store would answer. * - **Re-seed on collision** (`createWithAutonumberResync`): drop the stale diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 210be7b76d..d13d2ff8f9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -21,7 +21,7 @@ import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; // engine is what `metadata-protocol.validateData` returns, so letting the two // drift would put a translation layer between a verdict and its contract. import type { ValidateDataIssue, ValidateDataResponse } from '@objectstack/spec/api'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY, isCurrentUserDefaultToken, isNowDefaultToken } from '@objectstack/spec/data'; +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'; // [#5158] Door 2's lowering sink — the SAME pair the protocol face (Door 1) // runs, so `FilterArray` has exactly one lowering in the product. import { isFilterAST, parseFilterAST, VALID_AST_OPERATORS } from '@objectstack/spec/data'; @@ -352,39 +352,30 @@ interface IssuedAutonumber { * (#6806) so both readings can never drift apart — a divergence here is a * duplicate record number, which is the harm the whole family is about. * - * `prefix` and `suffix` are `renderAutonumber`'s own declared output; this - * function derives no format understanding of its own (see `seedAutonumber`'s - * "Locating the counter inside a stored value" section for the full rationale): + * The ANCHORED reading itself is NOT this package's (#6560): it is the inverse + * of `renderAutonumber`'s composition, so it lives beside it as spec's + * {@link readAutonumberCounter}, which the SQL driver's `scanMaxNumericTail` + * calls over the same two strings. This function adds only the piece the two + * sides genuinely do not share: * - * - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit - * run at the START of what follows the prefix, after removing the declared - * suffix when this row carries it. The suffix is stripped when it matches, - * never required to match — a dynamic suffix renders differently per row - * while the counter scope is the rendered PREFIX, so those rows share this - * counter and must still be read. - * - **Neither declared ⇒ UNANCHORED**: the legacy reading — the LAST digit - * run of the whole value. + * - **Either `prefix` or `suffix` declared ⇒ ANCHORED**: spec answers, and its + * TSDoc carries the rationale (counter after the prefix, suffix stripped + * when it matches and never required to match, out-of-scope values read as + * `undefined`). + * - **Neither declared ⇒ UNANCHORED**: this engine's own legacy reading — the + * LAST digit run of the whole value — kept byte-for-byte by PR #6553. The + * SQL driver's legacy reading of the same case is deliberately DIFFERENT (it + * concatenates every digit), which is exactly why spec refuses to answer for + * an unanchored slot instead of picking one of the two. * - * A value outside the scope (it does not carry the rendered prefix) reads as - * `undefined`: it belongs to another counter and must not lift this one. - * - * Both branches use linear `/\d+/` forms — a backtracking lookahead here is a - * polynomial-ReDoS sink on stored values full of zeros (CodeQL + * The unanchored branch uses the linear `/\d+/g` — a backtracking lookahead here + * is a polynomial-ReDoS sink on stored values full of zeros (CodeQL * js/polynomial-redos). */ -function readAutonumberCounter(value: string, prefix: string, suffix: string): number | undefined { - if (prefix && !value.startsWith(prefix)) return undefined; - const anchored = prefix !== '' || suffix !== ''; - let digits: string | undefined; - if (anchored) { - let core = value.slice(prefix.length); - if (suffix && core.endsWith(suffix)) core = core.slice(0, core.length - suffix.length); - const head = core.match(/^\d+/); - digits = head ? head[0] : undefined; - } else { - const runs = value.match(/\d+/g); - digits = runs ? runs[runs.length - 1] : undefined; - } +function readStoredAutonumberCounter(value: string, prefix: string, suffix: string): number | undefined { + if (prefix !== '' || suffix !== '') return readAutonumberCounter(value, prefix, suffix); + const runs = value.match(/\d+/g); + const digits = runs ? runs[runs.length - 1] : undefined; if (!digits) return undefined; const n = parseInt(digits, 10); return Number.isFinite(n) ? n : undefined; @@ -2592,9 +2583,9 @@ export class ObjectQL implements IObjectQLEngine { * (#6806) — the free half of the resync, and the one that closes the shape * #5495's PROBE1 measured on a warm database. * - * The value is parsed with {@link readAutonumberCounter}, i.e. by exactly the - * anchoring rules #6468 gave the seeding scan, against the prefix/suffix this - * record's own format renders. So adopting is the same reading a cold re-seed + * The value is parsed with {@link readStoredAutonumberCounter}, i.e. by + * exactly the anchoring rules #6468 gave the seeding scan, against the + * prefix/suffix this record's own format renders. So adopting is the same reading a cold re-seed * would perform over the same row — which is the invariant to hold on to: a * warm counter must answer what a restart would answer. * @@ -2611,8 +2602,8 @@ export class ObjectQL implements IObjectQLEngine { * persisted, so the first generating insert's own scan reads it. * - **Never lowers.** A counter that has already issued numbers must not go * back over them; the max is a floor that only rises. - * - **Only within this record's scope.** `readAutonumberCounter` returns - * `undefined` for a value that does not carry the rendered prefix, so a + * - **Only within this record's scope.** The reading returns `undefined` + * for a value that does not carry the rendered prefix, so a * historical import into last month's date scope cannot lift THIS * month's counter. Its own scope's counter is left untouched, which is * harmless: a scope is derived from the write instant, so a past scope's @@ -2647,7 +2638,7 @@ export class ObjectQL implements IObjectQLEngine { const counterKey = `${object}.${field}.${probe.scope}`; const seeded = this.autonumberCounters.get(counterKey); if (seeded == null) return; // not seeded yet — the first seed scan will read this row - const supplied = readAutonumberCounter(value, probe.prefix, probe.suffix); + const supplied = readStoredAutonumberCounter(value, probe.prefix, probe.suffix); if (supplied == null || supplied <= seeded) return; this.autonumberCounters.set(counterKey, supplied); this.logger.debug('Autonumber counter lifted to an externally supplied value', { @@ -2827,15 +2818,19 @@ export class ObjectQL implements IObjectQLEngine { * * - **Either one declared ⇒ the slot is ANCHORED**: the counter is the digit * run at the START of what follows the prefix, after removing the declared - * suffix when this row carries it. + * suffix when this row carries it. That half is spec's + * `readAutonumberCounter` — the declared inverse of `renderAutonumber`, + * which the SQL driver's scan calls too, so one edit moves both sides + * (#6560). * - **Neither declared ⇒ UNANCHORED**: the legacy reading is kept — the LAST * digit run of the whole value. A format with no `{0..0}` slot renders a * bare trailing counter, and values predating any format have no anchor to - * read from, so this stays exactly as it was. + * read from, so this stays exactly as it was. The SQL driver's legacy + * reading of this case differs on purpose, which is why it stays per-side. * - * That reading is {@link readAutonumberCounter}, module-level rather than - * inline, because #6806's resync must read an exempt writer's supplied value - * by exactly these rules. + * The two together are {@link readStoredAutonumberCounter}, module-level + * rather than inline, because #6806's resync must read an exempt writer's + * supplied value by exactly these rules. * * The suffix is *stripped when it matches*, never *required* to match: a * dynamic suffix renders differently per row (`{000}-{YYYY}` is `-2025` on @@ -2911,12 +2906,14 @@ export class ObjectQL implements IObjectQLEngine { for (const r of page) { const v = r?.[field]; if (v == null) continue; - // The reading itself lives in `readAutonumberCounter` (the section - // above describes it) because #6806's adopt-on-exempt-write resync - // must read a supplied value by the SAME rules this scan reads a - // stored one — two copies of it would drift into two different - // answers for one row, which is a duplicate record number. - const counter = readAutonumberCounter(String(v), prefix, suffix); + // The reading itself lives in `readStoredAutonumberCounter` (the + // section above describes it) because #6806's adopt-on-exempt-write + // resync must read a supplied value by the SAME rules this scan reads + // a stored one — two copies of it would drift into two different + // answers for one row, which is a duplicate record number. Its + // anchored half is spec's `readAutonumberCounter`, the one the SQL + // driver's own scan calls (#6560). + const counter = readStoredAutonumberCounter(String(v), prefix, suffix); if (counter != null) max = Math.max(max, counter); } } diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 490530976e..564b88fdc9 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -654,6 +654,7 @@ "parseFilterAST (function)", "percentScaleOf (function)", "provisionPrimary (function)", + "readAutonumberCounter (function)", "reduceFilterKeyVerdict (function)", "reduceFilterVerdict (function)", "referenceTargetOf (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 19082dbd30..c5e2bbf4db 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -654,6 +654,7 @@ "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", + "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)", "reduceFilterKeyVerdict": "src/data/filter-verdict.ts#reduceFilterKeyVerdict (function)", "reduceFilterVerdict": "src/data/filter-verdict.ts#reduceFilterVerdict (function)", "referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)", diff --git a/packages/spec/src/data/autonumber-format.test.ts b/packages/spec/src/data/autonumber-format.test.ts index 4a59f3ae84..514159a0a5 100644 --- a/packages/spec/src/data/autonumber-format.test.ts +++ b/packages/spec/src/data/autonumber-format.test.ts @@ -8,6 +8,7 @@ import { sequenceWidth, referencedFields, missingFieldValues, + readAutonumberCounter, } from './autonumber-format'; // A fixed instant: 2026-06-17 21:30 UTC. In Asia/Shanghai (UTC+8) this is @@ -156,3 +157,107 @@ describe('renderAutonumber', () => { expect(renderAutonumber({ tokens, seq: 2, now: NOW, record: {} }).value).toBe('002'); }); }); + +describe('readAutonumberCounter — the inverse of renderAutonumber (#6560)', () => { + // The rule moved here out of two hand-written copies (engine + driver-sql) + // that PR #6553 left behind; these cases mirror what that PR's own tests pin + // on each side, so a change here fails in spec before it can diverge there. + + describe('round-trips renderAutonumber', () => { + it.each([ + ['{000}-{YYYY}', 3], + ['CASE-{000}-{YYYY}', 42], + ['D-{0000}', 1], + ['AD{YYYYMMDD}{0000}', 999], + ['{section}{000}', 7], + ])('%s reads back the seq it rendered', (format, seq) => { + const tokens = parseAutonumberFormat(format); + const r = renderAutonumber({ tokens, seq, now: NOW, record: { section: 'X' } }); + expect(readAutonumberCounter(r.value, r.prefix, r.suffix)).toBe(seq); + }); + + it('reads back a counter that has grown past the pad width', () => { + const tokens = parseAutonumberFormat('{000}-{YYYY}'); + const r = renderAutonumber({ tokens, seq: 12345, now: NOW }); + expect(r.value).toBe('12345-2026'); + expect(readAutonumberCounter(r.value, r.prefix, r.suffix)).toBe(12345); + }); + }); + + describe('the counter is located by the declared pair, not by "the trailing digits"', () => { + it('takes the counter, not the year, from a value the year trails', () => { + // The #6468 defect verbatim: the last digit run of `003-2026` is the YEAR. + expect(readAutonumberCounter('003-2026', '', '-2026')).toBe(3); + expect(readAutonumberCounter('003-2026', '', '-2026')).not.toBe(2026); + }); + + it('reads between the prefix and the suffix when both are declared', () => { + expect(readAutonumberCounter('CASE-003-2026', 'CASE-', '-2026')).toBe(3); + }); + + it('does not concatenate the digit runs it finds', () => { + // The driver-sql half of the defect read this as 12026. + expect(readAutonumberCounter('001-2026', '', '-2026')).toBe(1); + }); + + it('strips a matching suffix but never requires one', () => { + // `{000}-{YYYY}` scopes its counter to the rendered PREFIX (''), so ONE + // counter spans the years: last year's row still holds counter 7 and must + // be read, or seeding lands BELOW the real max (#6249's harm). + expect(readAutonumberCounter('007-2025', '', '-2026')).toBe(7); + }); + + it('strips the suffix before reading when the suffix itself starts with a digit', () => { + // `{0000}{YYYY}` is ambiguous by construction (the compile lint nudges + // authors to a delimiter); the strip is what adds precision here. + expect(readAutonumberCounter('00072026', '', '2026')).toBe(7); + }); + + it('reads leading zeros as the padded counter they are', () => { + expect(readAutonumberCounter('D-0000', 'D-', '')).toBe(0); + expect(readAutonumberCounter('D-0004', 'D-', '')).toBe(4); + }); + }); + + describe('values that teach this counter nothing read as undefined', () => { + it('an unanchored slot — neither prefix nor suffix declared', () => { + // Deliberately NOT answered here: the engine reads the LAST digit run and + // the SQL driver concatenates every digit, both preserved byte-for-byte by + // PR #6553. Spec refuses rather than picking one of the two. + expect(readAutonumberCounter('SO-2024-0007', '', '')).toBeUndefined(); + expect(readAutonumberCounter('10', '', '')).toBeUndefined(); + }); + + it('a value from another scope', () => { + expect(readAutonumberCounter('OTHER-900-2026', 'CASE-', '-2026')).toBeUndefined(); + }); + + it('a value shorter than the affixes it is read against', () => { + expect(readAutonumberCounter('CA', 'CASE-', '-2026')).toBeUndefined(); + expect(readAutonumberCounter('', 'CASE-', '')).toBeUndefined(); + expect(readAutonumberCounter('', '', '-2026')).toBeUndefined(); + }); + + it('a non-numeric tail after the prefix', () => { + expect(readAutonumberCounter('CASE-DRAFT', 'CASE-', '')).toBeUndefined(); + expect(readAutonumberCounter('CASE-', 'CASE-', '')).toBeUndefined(); + // The digits are there but not where the format puts them. + expect(readAutonumberCounter('CASE-X-0007', 'CASE-', '')).toBeUndefined(); + }); + + it('a value whose whole content IS the suffix', () => { + expect(readAutonumberCounter('-2026', '', '-2026')).toBeUndefined(); + }); + + it('a counter too long to parse to a finite number', () => { + // `parseInt` of a ~400-digit run overflows to Infinity; an Infinite max + // would poison the counter, so it reads as "nothing" instead. + expect(readAutonumberCounter(`D-${'9'.repeat(400)}`, 'D-', '')).toBeUndefined(); + }); + }); + + it('is prefix-exact — a longer prefix does not match a shorter scope', () => { + expect(readAutonumberCounter('CASE-001', 'CASE-2026-', '')).toBeUndefined(); + expect(readAutonumberCounter('case-001', 'CASE-', '')).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts index 64d2e1d13c..eec394b33f 100644 --- a/packages/spec/src/data/autonumber-format.ts +++ b/packages/spec/src/data/autonumber-format.ts @@ -213,3 +213,75 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb : `${prefix}${String(seq).padStart(width, '0')}${suffix}`; return { prefix, suffix, scope, value }; } + +/** + * Read the counter back out of ONE stored record number — the inverse companion + * of {@link renderAutonumber}, and the reason this file's header says "shared by + * the ObjectQL engine and the SQL driver": both sides MUST call this rather than + * re-derive the rule, in either direction. + * + * # Why this lives here (#6560) + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, so a format + * with tokens AFTER the `{0..0}` slot renders a value that does NOT end in its + * counter (`{000}-{YYYY}` → `001-2026`). PR #6553 (#6468) taught both seeding + * paths to locate the counter by that declared pair — but as two hand-written + * copies of the same four lines, one in `packages/objectql` and one in + * `packages/drivers/driver-sql`. The defect they were fixing was *precisely* + * that shape: two independent readings of one composition rule had drifted into + * two DIFFERENT wrong answers over one dataset, so the record-number band a + * tenant received depended on which driver happened to run, and numbers burned + * that way are not reclaimable. + * + * The maintainer's ruling on #6560 (2026-08-10, and twice on 2026-08-08) puts + * the reverse rule beside the forward one: the composition is spec's, so its + * inverse is spec's, and no lower package exists for both consumers to share. + * Pure and non-authorable — no Zod, no vocabulary, nothing acceptance-facing. + * + * # The rule + * + * `prefix` and `suffix` are `renderAutonumber`'s own declared output for the + * record being written; a caller passing anything else is asking a question this + * function does not answer. + * + * - **Either one non-empty ⇒ the slot is ANCHORED**: the counter is the digit + * run at the START of what follows `prefix`, after removing `suffix` when + * this value carries it. + * - The suffix is stripped *when it matches*, never *required* to match. A + * dynamic suffix renders differently per row (`{000}-{YYYY}` is `-2025` on + * last year's rows) while the counter scope is the rendered PREFIX — `''` + * there — so those rows share this very counter and must still be read. + * Requiring the match would read BELOW the real max, which is the + * duplicate-record-number harm #6249 fixed on the scan side. + * - A value outside the scope (it does not carry `prefix`) reads as + * `undefined`: it belongs to another counter and must not lift this one. + * Callers that pre-filtered with a looser matcher — a SQL `LIKE` under a + * case-insensitive collation — get that re-check here for free. + * + * `undefined` means "this value teaches this counter nothing", never `0`. + * + * # What this deliberately does NOT decide + * + * An UNANCHORED slot (both strings empty) has no canonical reading, and this + * returns `undefined` for it rather than inventing one. The two consumers keep + * *different* legacy readings there — the engine takes the LAST digit run of the + * value, the SQL driver concatenates every digit — and PR #6553 preserved both + * byte-for-byte on purpose: a format with no `{0..0}` slot renders a bare + * trailing counter, and values predating any format have no anchor at all. + * Hoisting either one here would make this contract claim an agreement that does + * not exist. Each side documents its own fallback at its own call site. + * + * The digit match is the linear `/^\d+/` — a backtracking lookahead here is a + * polynomial-ReDoS sink on stored values full of zeros (CodeQL + * js/polynomial-redos). + */ +export function readAutonumberCounter(value: string, prefix: string, suffix: string): number | undefined { + if (prefix === '' && suffix === '') return undefined; + if (prefix && !value.startsWith(prefix)) return undefined; + let core = value.slice(prefix.length); + if (suffix && core.endsWith(suffix)) core = core.slice(0, core.length - suffix.length); + const head = core.match(/^\d+/); + if (!head) return undefined; + const n = parseInt(head[0], 10); + return Number.isFinite(n) ? n : undefined; +}