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
22 changes: 22 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
51 changes: 51 additions & 0 deletions packages/spec/src/data/autonumber-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
147 changes: 147 additions & 0 deletions packages/spec/src/data/autonumber-unanchored-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading