diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 543f3cc91a..409928480f 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -3629,6 +3629,28 @@ export class SqlDriver implements IDataDriver { * 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. * + * ## The unanchored arm is IMPLEMENTATION DETAIL, outside the declared contract (#7287) + * + * That refusal is now a DECLARED boundary, not merely an absent agreement: the + * #7287 ruling (2026-08-10, 「宣告边界」) states that a stored value carrying + * non-digit content on an unanchored format is out of contract for counter + * readback, and that `readAutonumberCounter`'s `undefined` for that slot is the + * contract rather than a gap. The boundary and its rationale live in that + * function's TSDoc in `packages/spec/src/data/autonumber-format.ts`; the pins + * are `packages/spec/src/data/autonumber-unanchored-boundary.test.ts`. + * + * So the concatenate-every-digit reading below is THIS DRIVER'S behavior, not a + * promise the platform makes. On the inputs the contract admits — pure-digit + * values, which is what `renderAutonumber` emits for an unanchored format — it + * answers the same number the engine does. On mixed-content values it may + * answer differently (`'SO-2024-0007'` → `20240007` here, `7` in the engine's + * `readStoredAutonumberCounter`), and that difference is out of contract on + * both sides, not a defect on either. + * + * Consequently the ruling moves nothing here: neither this reading nor the + * engine's was hoisted into the shared helper, precisely because doing so would + * move live behavior on the other side over record numbers already issued. + * * ## Why the suffix is NOT pushed into the LIKE * * `like 'prefix%suffix'` looks tempting and is wrong: the counter scope is the diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index f43be49db7..47af03335e 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -372,6 +372,27 @@ interface IssuedAutonumber { * concatenates every digit), which is exactly why spec refuses to answer for * an unanchored slot instead of picking one of the two. * + * # The unanchored arm is IMPLEMENTATION DETAIL, outside the declared contract (#7287) + * + * Spec's refusal above is no longer just an absence of agreement: the #7287 + * ruling (2026-08-10, 「宣告边界」) DECLARES that a stored value carrying + * non-digit content on an unanchored format is out of contract for counter + * readback, and that `readAutonumberCounter`'s `undefined` for that slot is the + * contract rather than a gap. The boundary and its rationale live in that + * function's TSDoc in `packages/spec/src/data/autonumber-format.ts`; the pins + * are `packages/spec/src/data/autonumber-unanchored-boundary.test.ts`. + * + * So the last-digit-run reading below is THIS ENGINE'S behavior, not a promise + * the platform makes. On the inputs the contract admits — pure-digit values, + * which is what `renderAutonumber` emits for an unanchored format — it answers + * the same number the SQL driver does. On mixed-content values it may answer + * differently (`'SO-2024-0007'` → `7` here, `20240007` there), and that + * difference is out of contract on both sides, not a defect on either. + * + * Consequently the ruling moves nothing here: neither this reading nor the + * driver's was hoisted into the shared helper, precisely because doing so would + * move live behavior on the other side over record numbers already issued. + * * 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). diff --git a/packages/spec/src/data/autonumber-format.ts b/packages/spec/src/data/autonumber-format.ts index 9bdb37be28..aeae0b85a7 100644 --- a/packages/spec/src/data/autonumber-format.ts +++ b/packages/spec/src/data/autonumber-format.ts @@ -336,6 +336,57 @@ export function renderAutonumber(input: RenderAutonumberInput): RenderedAutonumb * 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 declared boundary: mixed-content unanchored values are OUT OF CONTRACT (#7287) + * + * That refusal is not a gap waiting to be filled — as of the maintainer's ruling + * on #7287 (2026-08-10, 「宣告边界」, treatment (2)) it IS the contract: + * + * > **On an UNANCHORED format (`prefix === '' && suffix === ''`), a stored value + * > carrying any non-digit content is out of contract for counter readback.** + * > `undefined` is this function's answer for that slot, permanently. + * + * Why declare it rather than pick a reading. The two consumers have read such a + * value differently since before this helper existed, and both readings are + * pinned by their own tests: + * + * - the engine's `readStoredAutonumberCounter` + * (`packages/objectql/src/engine.ts`) takes the **last digit run** — + * `'SO-2024-0007'` → `7`; + * - the SQL driver's `scanMaxNumericTail` + * (`packages/drivers/driver-sql/src/sql-driver.ts`) **concatenates every + * digit** — the same value → `20240007`. + * + * Ruling either one to be the contract would move live behavior on the other + * side, over stored business identifiers that are not reclaimable once issued — + * which is why #7247 refused the hoist as a rider, and why #7287 rejects it + * outright rather than picking a side. Declaring the input out of contract costs + * nothing that was ever promised: no reading was ever specified for it. + * + * The boundary is drawn by CONTENT, and deliberately wider than the observed + * divergence. A **pure-digit** value — which is exactly what `renderAutonumber` + * emits for an unanchored format — is inside the contract, and both sides answer + * the same number for it: `'0007'` reads as `7` either way. Everything else is + * outside, including mixed-content values the two readings happen to agree on + * (`'CASE-12'` is `12` both ways: divergence needs TWO digit runs, and that one + * has one). Narrowing the boundary to "values the sides actually disagree on" + * would make it undecidable from the value alone — a caller would have to know + * which consumer is running to know whether its input is in contract, which is + * the very "same metadata, different driver, different number" property this + * boundary exists to bound. So the out-of-contract face is simply + * legacy/migrated data: a dash-separated string in a column later declared + * autonumber. + * + * Reachability, and why the boundary is worth stating now: #6555's Route-3 + * ruling (PR #7265) made `{0000}` the declared default for format-less + * autonumber fields, and `{0000}` renders neither prefix nor suffix — so the + * DEFAULT authoring shape resolves to this unanchored slot on both sides. + * + * Consequence for the consumers, also recorded by the ruling: neither moves. + * Each keeps its current unanchored reading as **implementation detail outside + * this boundary**, not as a contract this file underwrites. See the pins in + * `autonumber-unanchored-boundary.test.ts`, and the ruling itself at + * https://github.com/objectstack-ai/objectstack/issues/7287#issuecomment-5238560707. + * * 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). diff --git a/packages/spec/src/data/autonumber-unanchored-boundary.test.ts b/packages/spec/src/data/autonumber-unanchored-boundary.test.ts new file mode 100644 index 0000000000..bc64f1b6e2 --- /dev/null +++ b/packages/spec/src/data/autonumber-unanchored-boundary.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pins for the contract boundary declared on #7287 (maintainer ruling + * 2026-08-10, 「宣告边界」, treatment (2)): + * + * On an UNANCHORED format (`prefix === '' && suffix === ''`), a stored value + * carrying non-digit content is OUT OF CONTRACT for counter readback. + * `readAutonumberCounter`'s `undefined` for that slot is the contract, not a + * gap — permanently. + * + * These cases exist to make that boundary fail loudly if a later change tries to + * fill the slot in: hoisting the engine's reading (last digit run) or the SQL + * driver's (concatenate every digit) into the shared helper would move live + * behavior on the other side, over record numbers that are not reclaimable once + * issued. #7247 refused that hoist as a rider; #7287 rejects it outright. + * + * The tests below assert only `readAutonumberCounter` — spec's declared surface. + * The two consumers' readings are modelled here ONLY to show WHY the slot is out + * of contract (they disagree on exactly the inputs the boundary excludes, and + * agree on the ones it admits); neither model is a contract, and the real + * implementations live at their own call sites. + */ + +import { describe, it, expect } from 'vitest'; +import { parseAutonumberFormat, renderAutonumber, readAutonumberCounter } from './autonumber-format'; + +const NOW = new Date('2026-06-17T21:30:00.000Z'); + +/** + * The engine's unanchored reading, mirrored from `readStoredAutonumberCounter` + * in `packages/objectql/src/engine.ts` — the LAST digit run of the value. + * Illustration only: this file pins nothing about the engine. + */ +function engineUnanchoredReading(value: string): number | undefined { + const runs = value.match(/\d+/g); + const digits = runs ? runs[runs.length - 1] : undefined; + return digits ? parseInt(digits, 10) : undefined; +} + +/** + * The SQL driver's unanchored reading, mirrored from `scanMaxNumericTail` in + * `packages/drivers/driver-sql/src/sql-driver.ts` — EVERY digit, concatenated. + * Illustration only: this file pins nothing about the driver. + */ +function driverSqlUnanchoredReading(value: string): number | undefined { + const n = parseInt(value.replace(/[^0-9]/g, ''), 10); + return Number.isFinite(n) ? n : undefined; +} + +describe('the unanchored readback boundary (#7287 ruling: mixed content is out of contract)', () => { + it('the declared default format {0000} (#6555, PR #7265) really does render an UNANCHORED pair', () => { + // The premise the boundary hangs on: the default authoring shape is the one + // that lands in this slot, which is why the boundary is worth declaring. + const r = renderAutonumber({ tokens: parseAutonumberFormat('{0000}'), seq: 7, now: NOW }); + expect(r.prefix).toBe(''); + expect(r.suffix).toBe(''); + expect(r.value).toBe('0007'); + }); + + describe('OUT OF CONTRACT — non-digit content on an unanchored format reads as undefined', () => { + it.each([ + ['A1B2', 'digits split by letters — the shape the two readings disagree on most sharply'], + ['CASE-12', 'a dash-separated legacy value against a bare {0000}'], + ['SO-2024-0007', 'the issue-body example: engine reads 7, driver-sql reads 20240007'], + ['2026-06-17', 'a date-looking value in a column later declared autonumber'], + ['INV/0042', 'a separator other than a dash — the boundary is content, not punctuation'], + ['0007 (void)', 'digits first, non-digit content trailing'], + ['v2.1', 'digits inside an unrelated version-shaped identifier'], + ['DRAFT', 'no digits at all — still out of contract, and still undefined'], + ])( + 'out of contract: %s reads as undefined — the declared boundary, not a gap (#7287)', + (value) => { + expect(readAutonumberCounter(value, '', '')).toBeUndefined(); + }, + ); + + it.each([ + ['A1B2'], + ['SO-2024-0007'], + ['2026-06-17'], + ['v2.1'], + ])( + 'why %s is out of contract: the two consumers read it as DIFFERENT numbers', + (value) => { + expect(engineUnanchoredReading(value)).not.toBe(driverSqlUnanchoredReading(value)); + // And spec declines to break the tie — that refusal IS the contract. + expect(readAutonumberCounter(value, '', '')).toBeUndefined(); + }, + ); + + it.each([['CASE-12', 12], ['INV/0042', 42], ['DRAFT-7', 7]])( + 'out of contract even though the two consumers happen to agree on %s (%i)', + (value, agreed) => { + // The boundary is drawn by CONTENT, deliberately wider than the observed + // divergence: divergence needs TWO digit runs, so a mixed-content value + // carrying only one reads the same number either way. Narrowing the + // boundary to "values the two sides actually disagree on" would make it + // undecidable from the value alone — a caller would have to know which + // consumer is running to know whether it is in contract, which is the + // very "same metadata, different driver, different number" property + // #7287 exists to bound. So: still out of contract, still undefined. + expect(engineUnanchoredReading(value)).toBe(agreed); + expect(driverSqlUnanchoredReading(value)).toBe(agreed); + expect(readAutonumberCounter(value, '', '')).toBeUndefined(); + }, + ); + }); + + describe('INSIDE the contract — pure-digit unanchored values, where both sides agree', () => { + it.each([['0007', 7], ['10', 10], ['0000', 0], ['123456', 123456]])( + 'in contract: %s carries no non-digit content, so both consumers read %i', + (value, expected) => { + expect(engineUnanchoredReading(value)).toBe(expected); + expect(driverSqlUnanchoredReading(value)).toBe(expected); + }, + ); + + it('what renderAutonumber itself emits for {0000} stays inside the boundary', () => { + // The in-contract face is the one the platform generates: an unanchored + // format renders pure digits, so a store the platform filled reads the + // same number on either side. The out-of-contract face is legacy/migrated + // data that arrived by some other path. + const tokens = parseAutonumberFormat('{0000}'); + for (const seq of [0, 7, 42, 9999, 123456]) { + const { value } = renderAutonumber({ tokens, seq, now: NOW }); + expect(engineUnanchoredReading(value)).toBe(seq); + expect(driverSqlUnanchoredReading(value)).toBe(seq); + } + }); + + it('agreement on the digits does NOT make the shared helper answer for the slot', () => { + // The boundary is about which INPUTS are in contract; the shared helper's + // declared job stays the ANCHORED inverse of renderAutonumber. Even where + // the two consumers agree, spec does not underwrite an unanchored reading + // — each side answers from its own implementation detail. + expect(readAutonumberCounter('0007', '', '')).toBeUndefined(); + }); + }); + + it('an anchored format is untouched by this boundary — non-digit content still reads normally there', () => { + // Guard against the boundary being read too widely: it is scoped to the + // unanchored slot. `CASE-0007` against the pair `{prefix: 'CASE-'}` is in + // contract and answers 7, mixed content and all. + expect(readAutonumberCounter('CASE-0007', 'CASE-', '')).toBe(7); + }); +});