Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/autonumber-counter-readback-shared.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 18 additions & 12 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/objectql/src/engine-autonumber-resync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 44 additions & 47 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@
"parseFilterAST (function)",
"percentScaleOf (function)",
"provisionPrimary (function)",
"readAutonumberCounter (function)",
"reduceFilterKeyVerdict (function)",
"reduceFilterVerdict (function)",
"referenceTargetOf (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
Loading
Loading