From 83ce0e506fc81a1655e4ff54708a4db3cf8147e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:18:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(objectql,driver-sql):=20=E6=92=AD=E7=A7=8D?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=8C=89=E5=A3=B0=E6=98=8E=E7=9A=84=20suffix?= =?UTF-8?q?=20=E5=AE=9A=E4=BD=8D=E8=AE=A1=E6=95=B0=E5=99=A8=20(#6468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自增号格式允许序号槽 `{0..0}` 之后还有 token(`renderAutonumber` 的 `suffix` 是声明返回值,契约为 `prefix + zero-padded(seq) + suffix`),这类格式渲染出的 值序号不在串尾。两侧播种解析都假定「串尾数字段 = 计数器」,各错各的:引擎 `seedAutonumber` 取最后一个数字段读成年份 2026,driver-sql `scanMaxNumericTail` 把 tail 全部数字拼接读成 12026,真实计数器是 1 —— 同一份元数据换驱动号段不同, 跳过的号无法回收。 两侧改为:prefix / suffix 任一非空则计数器「有锚」,取 prefix 之后首个数字段 (该行带声明 suffix 时先剥离);两者皆空则各自既有读法逐字保留。两个字符串都由 调用方从 renderAutonumber 取得后传入,两侧都不再自行理解格式。 suffix 只在匹配时剥离、不要求匹配,SQL 谓词也保持 `like 'prefix%'`:计数器 scope 是渲染后的 prefix,`{000}-{YYYY}` 全局一个计数器,去年的 `007-2025` 仍 持有计数器 7,按当前 suffix 过滤会播种低于真实 max(#6249 的重复单号伤害)。 不触 getNextSequenceValue 的序列逻辑(仅多转发一个位置参数)、不触 #6467 的 扫描结构、不改 packages/spec。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We --- .changeset/autonumber-seed-suffix-parse.md | 41 +++ .../src/sql-driver-autonumber-suffix.test.ts | 191 +++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 61 +++- .../src/engine-autonumber-seed-suffix.test.ts | 308 ++++++++++++++++++ packages/objectql/src/engine.ts | 58 +++- ...seed-cross-side-parity.integration.test.ts | 165 ++++++++++ 6 files changed, 807 insertions(+), 17 deletions(-) create mode 100644 .changeset/autonumber-seed-suffix-parse.md create mode 100644 packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts create mode 100644 packages/objectql/src/engine-autonumber-seed-suffix.test.ts create mode 100644 packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts diff --git a/.changeset/autonumber-seed-suffix-parse.md b/.changeset/autonumber-seed-suffix-parse.md new file mode 100644 index 0000000000..1ab2882df6 --- /dev/null +++ b/.changeset/autonumber-seed-suffix-parse.md @@ -0,0 +1,41 @@ +--- +"@objectstack/objectql": patch +"@objectstack/driver-sql": patch +--- + +fix(objectql,driver-sql): 自增号播种按声明的 `suffix` 定位计数器,两侧收敛到同一答案 (#6468) + +`autonumberFormat` 允许序号槽 `{0..0}` **后面**还有 token —— `renderAutonumber` +专门返回 `suffix`,其契约就是 `prefix + zero-padded(seq) + suffix`。这类格式渲染 +出的值**序号不在串尾**:`{000}-{YYYY}` 渲染成 `001-2026`,是很常见的单号写法。 + +两侧的播种解析却都假定「串尾的数字就是计数器」,而且各错各的: + +- 引擎兜底播种 `seedAutonumber()` 取整串的**最后一个**数字段 —— 读到的是年份。 + 库里三行 `001-2026`/`002-2026`/`003-2026`(真实计数器 3)把计数器播种成 **2026**, + 下一个发出的号直接跳到 `2027-2026`; +- driver-sql 的 `scanMaxNumericTail()` 把 tail 里**所有**数字拼接后 `parseInt` —— + 同样三行读成 **12026**,下一个号是 `12027-2026`。 + +于是**同一份元数据、同一批行,换个驱动号段就不一样**;中间跳过的号已经烧掉,事后 +无法回收。只修一侧会把「两个不同的错误答案」变成「一个对一个错」,跨驱动仍不一致, +所以两侧同 PR 修。 + +**修法:两侧解析器尊重已声明的 `prefix`/`suffix`。** 两个字符串都由调用方从 +`renderAutonumber` 的返回值取得后传入 —— 两侧都不再自行理解格式,driver-sql 只收 +参数(`getNextSequenceValue` 仅多转发一个位置参数,序列逻辑本身未动): + +- **prefix / suffix 任一非空 ⇒ 计数器「有锚」**:取 prefix 之后的**首个**数字段, + 并在该行确实带有声明的 suffix 时先把它去掉; +- **两者皆空 ⇒ 「无锚」**:各自的既有读法**逐字保留**(引擎取整串最后一个数字段, + driver-sql 拼接全部数字)—— 无 `{0..0}` 槽的格式渲染的就是串尾裸计数器,而早于 + 格式存在的历史值根本没有锚可依。 + +**suffix 只在匹配时剥离,绝不要求匹配。** `{000}-{YYYY}` 的计数器 scope 是渲染后的 +**prefix**(此处为空),即全局一个计数器、只有显示的年份在变,所以去年的 `007-2025` +持有计数器 7,必须计入。把 suffix 下推成 `like '%-2026'` 会把这些行整批漏掉、播种 +**低于**真实 max —— 那正是 #6249 修掉的重复单号伤害,自己再造一遍。因此 SQL 谓词 +保持 `like 'prefix%'`,suffix 只在 JS 侧逐行使用。 + +无后缀格式(`D-{0000}`、`{0000}`)两侧本来就正确,行为不变并已 pin 住;#6467 的 +播种扫描结构未触碰。 diff --git a/packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts new file mode 100644 index 0000000000..f14e440a9a --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6468 — `scanMaxNumericTail` must locate the counter by the format's DECLARED + * `suffix` instead of concatenating every digit it finds after the prefix. + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, and `suffix` + * is a declared return value: tokens after the `{0..0}` slot render BEHIND the + * counter, so `{000}-{YYYY}` produces `001-2026` — a value that does not end in + * its counter. Stripping the non-digits and parsing what is left turned `001-2026` + * into `12026`, so a table holding counters 1..3 bootstrapped its sequence at + * 12026 and the next record number jumped to `12027-2026`. Numbers burned that + * way are not reclaimable. + * + * The engine's fallback `seedAutonumber` read the same rows as `2026` — a + * DIFFERENT wrong answer, so the same metadata over the same rows produced a + * different band depending on which driver ran. The two sides now apply one rule + * to one pair of strings (`renderAutonumber`'s own `prefix`/`suffix`, computed by + * the caller and passed down); the convergence itself is pinned in + * `packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts`. + * + * Formats with no suffix — `D-{0000}`, `{0000}` — were already correct here and + * are pinned below against drift, as is the unanchored legacy reading. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SqlDriver } from '../src/index.js'; + +/** Date tokens render from the wall clock, so the clock is pinned. Only `Date`. */ +const FIXED_NOW = new Date('2026-06-15T09:00:00Z'); + +describe('SqlDriver autonumber seeding — the counter is located by the declared suffix (#6468)', () => { + let driver: SqlDriver; + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(FIXED_NOW); + driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + }); + + afterEach(async () => { + await driver.disconnect(); + vi.useRealTimers(); + }); + + /** Register one object whose single autonumber field carries `format`. */ + async function initRec(format?: string) { + await driver.initObjects([ + { + name: 'rec', + fields: { + title: { type: 'string' }, + rec_no: format === undefined ? { type: 'autonumber' } : { type: 'autonumber', format }, + }, + }, + ] as any); + } + + /** Land pre-existing record numbers directly, bypassing the sequence. */ + async function seedRows(values: string[]) { + const k = (driver as any).knex; + await k('rec').insert(values.map((v, i) => ({ id: `l${i + 1}`, rec_no: v, title: `legacy ${i + 1}` }))); + } + + // ------------------------------------- (1) suffix, no prefix — the defect -- + + describe('`{000}-{YYYY}` — the counter leads, the year trails', () => { + it('bootstraps from the counter (3), not from the digits concatenated (12026)', async () => { + await initRec('{000}-{YYYY}'); + await seedRows(['001-2026', '002-2026', '003-2026']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('004-2026'); + // The precise regression: `parseInt('0012026')` seeded 12026. + expect(r.rec_no).not.toBe('12027-2026'); + }); + + it('scans the max as the counter value itself', async () => { + await initRec('{000}-{YYYY}'); + await seedRows(['001-2026', '002-2026', '003-2026']); + + // Straight at the seeding scan: prefix '', suffix '-2026'. + const max = await (driver as any).scanMaxNumericTail( + (driver as any).knex, + 'rec', + 'rec_no', + '', + null, + null, + '-2026', + ); + + expect(max).toBe(3); + }); + + it('counts rows whose suffix rendered differently — one counter spans the years', async () => { + // The counter scope is the rendered PREFIX, '' here, so `{000}-{YYYY}` keeps + // ONE counter and only the displayed year moves. Last year's `007-2025` + // holds counter 7 and must be counted; a `like '%-2026'` predicate would + // drop it and re-issue 004..007 over rows that already exist. + await initRec('{000}-{YYYY}'); + await seedRows(['005-2025', '006-2025', '007-2025', '003-2026']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('008-2026'); + }); + }); + + // --------------------------------------- (2) suffix AND prefix — same rule -- + + describe('`CASE-{000}-{YYYY}` — text on both sides of the slot', () => { + it('reads the counter between the prefix and the suffix', async () => { + await initRec('CASE-{000}-{YYYY}'); + await seedRows(['CASE-001-2026', 'CASE-002-2026', 'CASE-003-2026']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('CASE-004-2026'); + }); + + it('ignores rows outside the prefix scope', async () => { + await initRec('CASE-{000}-{YYYY}'); + await seedRows(['CASE-001-2026', 'CASE-002-2026', 'OTHER-900-2026']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('CASE-003-2026'); + }); + }); + + // ------------------------------------------------ (3) controls — unchanged -- + + describe('formats with no suffix keep their existing behaviour', () => { + it('`D-{0000}` bootstraps from the digit run after the prefix', async () => { + await initRec('D-{0000}'); + await seedRows(['D-0001', 'D-0002', 'D-0003']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('D-0004'); + }); + + it('`{0000}` bootstraps from the bare padded counter', async () => { + await initRec('{0000}'); + await seedRows(['0001', '0002', '0003']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('0004'); + }); + }); + + // ------------------------------------------- (4) legacy unanchored reading -- + + describe('a format declaring neither prefix nor suffix keeps the legacy reading', () => { + it('keeps the digits-concatenated reading of the whole value', async () => { + // No format at all. With nothing to anchor on, the legacy reading stands + // byte-for-byte: `'10'` wins over `'2'` — a numeric max, never a + // lexicographic one — so the counter continues at 11. + // + // The RENDERING of a format-less field is a separate, pre-existing matter + // this fix does not touch: this driver substitutes `{0000}` for a missing + // format (see `initObjects`), so 11 renders `0011` here while the engine's + // fallback emits the bare `11`. That divergence is in the render default, + // not in the seeding parse #6468 is about, so the cross-side parity test + // uses explicitly-formatted fields. + await initRec(); + await seedRows(['1', '2', '10']); + + const r = await driver.create('rec', { title: 'next' }); + + expect(r.rec_no).toBe('0011'); + }); + + it('still concatenates the digits of values no format describes', async () => { + await initRec(); + await seedRows(['SO-2024-0007']); + + const max = await (driver as any).scanMaxNumericTail((driver as any).knex, 'rec', 'rec_no', '', null, null, ''); + + // 2024 and 0007 run together, exactly as before — unanchored is unchanged. + expect(max).toBe(20240007); + }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a02f35a9ab..cda626be1b 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -3026,9 +3026,39 @@ export class SqlDriver implements IDataDriver { } /** - * Bootstrap helper: scan the data table for the highest numeric suffix - * matching `prefix` (optionally scoped to a tenant). Used the first time - * a sequence row is created so legacy/seeded data continues monotonically. + * Bootstrap helper: scan the data table for the highest counter value among + * the values matching `prefix` (optionally scoped to a tenant). Used the first + * time a sequence row is created so legacy/seeded data continues monotonically. + * + * # Where the counter sits in a stored value (#6468) + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, so a format + * with tokens AFTER the `{0..0}` slot (`{000}-{YYYY}` → `001-2026`) does not + * end in the counter. Concatenating every digit of the tail read that as + * `12026` against a true counter of `1`, and the engine's own fallback seeding + * read the same row as `2026` — two different wrong answers for one dataset, + * so the issued band depended on which driver ran. + * + * `prefix` and `suffix` are `renderAutonumber`'s own output, computed by the + * caller and passed down: this driver derives no format understanding of its + * own, and the engine's `seedAutonumber` applies the identical rule to the + * identical two strings. + * + * - **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. + * - **Neither declared ⇒ UNANCHORED**: the legacy reading (every digit in + * the value, concatenated) is kept byte-for-byte. + * + * ## Why the suffix is NOT pushed into the LIKE + * + * `like 'prefix%suffix'` looks tempting and is wrong: the counter scope is the + * rendered PREFIX, so `{000}-{YYYY}` keeps ONE counter across years while its + * suffix renders `-2025` on last year's rows. Filtering on the current + * suffix would drop exactly those rows and seed BELOW the real max — the + * duplicate-record-number harm, self-inflicted. The predicate therefore stays + * `prefix%` and the suffix is applied per row, where a non-match simply means + * "different suffix, same counter". */ protected async scanMaxNumericTail( queryRunner: Knex | Knex.Transaction, @@ -3037,6 +3067,7 @@ export class SqlDriver implements IDataDriver { prefix: string, tenantField: string | null, tenantId: string | null, + suffix = '', ): Promise { const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1'); let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field); @@ -3045,11 +3076,25 @@ export class SqlDriver implements IDataDriver { } const rows = await builder; let maxN = 0; + const anchored = prefix !== '' || suffix !== ''; for (const r of rows as any[]) { const v: string = (r as any)[field]; if (typeof v !== 'string') continue; - const tail = v.slice(prefix.length); - const n = parseInt(tail.replace(/[^0-9]/g, ''), 10); + 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); + } else { + // Unanchored: `prefix` is '' here, so this is the whole value. + n = parseInt(v.replace(/[^0-9]/g, ''), 10); + } if (Number.isFinite(n) && n > maxN) maxN = n; } return maxN; @@ -3079,6 +3124,10 @@ export class SqlDriver implements IDataDriver { tenantId: string | null, parentTrx?: Knex.Transaction, scope = '', + // Rendered text AFTER the sequence slot — forwarded verbatim to the + // bootstrap scan so it can find the counter in values that do not end in it + // (#6468). Purely positional plumbing; no sequencing logic reads it. + suffix = '', ): Promise { // Pass the caller's transaction so a cold-cache first write inside a batch // transaction ensures the table on the right connection instead of dead- @@ -3131,6 +3180,7 @@ export class SqlDriver implements IDataDriver { prefix, tenantField, resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId, + suffix, ); const initial = seedMax + 1; try { @@ -3207,6 +3257,7 @@ export class SqlDriver implements IDataDriver { tenantId, parentTrx, probe.scope, + probe.suffix, ); row[cfg.name] = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value; } diff --git a/packages/objectql/src/engine-autonumber-seed-suffix.test.ts b/packages/objectql/src/engine-autonumber-seed-suffix.test.ts new file mode 100644 index 0000000000..6143c81412 --- /dev/null +++ b/packages/objectql/src/engine-autonumber-seed-suffix.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6468 — `seedAutonumber` must locate the counter by the format's DECLARED + * `suffix`, not by "the digits at the end of the string". + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, and `suffix` + * is a declared return value (`packages/spec/src/data/autonumber-format.ts`): + * every token after the `{0..0}` slot renders BEHIND the counter. So a perfectly + * ordinary invoice shape — `{000}-{YYYY}` → `001-2026` — does not end in its + * counter. The seeding read took the last digit run of the value regardless, + * which is the YEAR: with three rows numbered 1..3 it seeded `2026`, and the + * next issued number jumped to `2027-2026`. Every number in between is burned + * and cannot be reclaimed after the fact. + * + * The SQL driver's `scanMaxNumericTail` read the same rows as `12026` (it + * concatenated every digit of the tail), so the same metadata over the same rows + * produced two DIFFERENT wrong answers depending on which driver ran. The + * cross-side parity is pinned directly in + * `packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts`; + * this file pins the engine half. + * + * ## What is deliberately NOT changed + * + * A format that declares neither prefix nor suffix leaves the slot unanchored, + * and there the legacy reading (LAST digit run of the whole value) is kept — + * a format with no `{0..0}` slot renders a bare trailing counter, and values + * predating any format have no anchor to read from. Both control formats from + * the issue body (`D-{0000}`, `{0000}`) were already correct on both sides and + * are pinned here against drift. + * + * These tests drive a fake DRIVER (not a fake engine), so no engine write-verb + * dispatch contract is involved. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ObjectQL } from './engine'; +import { SchemaRegistry } from './registry'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +vi.mock('./registry', () => { + const instance: any = { + getObject: vi.fn(), + resolveObject: vi.fn((n: string) => instance.getObject(n)), + registerObject: vi.fn(), + getObjectOwner: vi.fn(), + registerNamespace: vi.fn(), + registerKind: vi.fn(), + registerItem: vi.fn(), + registerApp: vi.fn(), + installPackage: vi.fn(), + reset: vi.fn(), + metadata: { get: vi.fn(() => new Map()) }, + }; + function SchemaRegistry() { + return instance; + } + Object.assign(SchemaRegistry, instance); + return { + SchemaRegistry, + computeFQN: (_ns: string | undefined, name: string) => name, + parseFQN: (fqn: string) => ({ namespace: undefined, shortName: fqn }), + RESERVED_NAMESPACES: new Set(['base', 'system']), + }; +}); + +/** + * The date tokens render from the wall clock, so the clock is pinned. Only + * `Date` is faked — the seeding walk is ordinary async I/O and must keep running + * on real timers. + */ +const FIXED_NOW = new Date('2026-06-15T09:00:00Z'); + +interface CapturedQuery { + where?: any; + orderBy?: any; + limit?: number; + fields?: string[]; +} + +/** + * Evaluate the operators the seeding walk actually emits. Anything else throws + * rather than being tolerated: silently ignoring an unknown operator would let a + * bad query pass as a good one. + */ +function matches(row: Record, where: any): boolean { + if (where == null) return true; + for (const [key, cond] of Object.entries(where)) { + if (key === '$and') { + if (!(cond as any[]).every((w) => matches(row, w))) return false; + continue; + } + if (key.startsWith('$')) throw new Error(`fake driver: unsupported logical operator ${key}`); + const v = row[key]; + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + for (const [op, operand] of Object.entries(cond as Record)) { + if (op === '$startsWith') { + if (typeof v !== 'string' || !v.startsWith(String(operand))) return false; + } else if (op === '$gt') { + if (!(String(v) > String(operand))) return false; + } else if (op === '$eq') { + if (v !== operand) return false; + } else { + throw new Error(`fake driver: unsupported operator ${op}`); + } + } + } else if (v !== cond) { + return false; + } + } + return true; +} + +function makeDriver(rows: Array>): IDataDriver & { + created: any[]; + queries: CapturedQuery[]; +} { + const created: any[] = []; + const queries: CapturedQuery[] = []; + const driver: any = { + name: 'memory', + version: '0.0.0', + // No `autonumber` support — this is exactly the engine fallback path. + supports: {}, + connect: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + checkHealth: vi.fn().mockResolvedValue(true), + execute: vi.fn(), + find: vi.fn(async (_obj: string, ast: any) => { + queries.push(JSON.parse(JSON.stringify({ where: ast?.where, orderBy: ast?.orderBy, limit: ast?.limit, fields: ast?.fields }))); + let out = rows.filter((r) => matches(r, ast?.where)); + const orderBy = ast?.orderBy; + if (Array.isArray(orderBy) && orderBy.length > 0) { + const { field, order } = orderBy[0]; + out = [...out].sort((a, b) => { + const av = String(a[field] ?? ''); + const bv = String(b[field] ?? ''); + const cmp = av < bv ? -1 : av > bv ? 1 : 0; + return order === 'desc' ? -cmp : cmp; + }); + } + if (typeof ast?.limit === 'number') out = out.slice(0, ast.limit); + return out.map((r) => ({ ...r })); + }), + findOne: vi.fn(), + create: vi.fn(async (_obj: string, row: any) => { + created.push(row); + return { id: `new${created.length}`, ...row }; + }), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }; + driver.created = created; + driver.queries = queries; + return driver as any; +} + +const rowId = (n: number) => `r${String(n).padStart(6, '0')}`; + +/** Build a schema whose single autonumber field carries `format`. */ +function schemaWith(field: string, format?: string) { + return { + name: 'rec', + fields: { + title: { type: 'text' }, + ...(format === undefined + ? { [field]: { type: 'autonumber', required: true } } + : { [field]: { type: 'autonumber', required: true, format } }), + }, + }; +} + +/** Seed rows carrying pre-existing record numbers, in insertion order. */ +function storedRows(field: string, values: string[]) { + return values.map((v, i) => ({ id: rowId(i + 1), [field]: v })); +} + +async function insertOne(schema: any, rows: Array>) { + vi.mocked(SchemaRegistry.getObject).mockReturnValue(schema as any); + const driver = makeDriver(rows); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + const result = await engine.insert('rec', { title: 'next' }); + return { result, driver }; +} + +describe('ObjectQL seedAutonumber — the counter is located by the declared suffix (#6468)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ------------------------------------- (1) suffix, no prefix — the defect -- + + describe('`{000}-{YYYY}` — the counter leads, the year trails', () => { + const SCHEMA = schemaWith('case_no', '{000}-{YYYY}'); + + it('seeds from the counter (3), not from the year (2026)', async () => { + const { result } = await insertOne(SCHEMA, storedRows('case_no', ['001-2026', '002-2026', '003-2026'])); + + expect(result.case_no).toBe('004-2026'); + // The precise regression: the last digit run of `003-2026` is the YEAR, so + // the counter seeded 2026 and the next number jumped a full band. + expect(result.case_no).not.toBe('2027-2026'); + }); + + it('counts rows whose suffix rendered differently — one counter spans the years', async () => { + // The counter scope is the rendered PREFIX, which is '' here: `{000}-{YYYY}` + // keeps ONE global counter and only the displayed year moves. Last year's + // `007-2025` therefore holds counter 7 and must be counted, even though it + // does not carry the suffix this row renders (`-2026`). Requiring the + // suffix to match would seed 3 and re-issue 004..007. + const { result } = await insertOne( + SCHEMA, + storedRows('case_no', ['005-2025', '006-2025', '007-2025', '003-2026']), + ); + + expect(result.case_no).toBe('008-2026'); + }); + + it('does not push a prefix down when the format declares none', async () => { + // #6467's scan shape is untouched by this fix: an empty prefix means no + // `$startsWith` filter, so every row of the object is visited. + const { driver } = await insertOne(SCHEMA, storedRows('case_no', ['001-2026'])); + + const seeds = driver.queries.filter((q) => Array.isArray(q.fields) && q.fields.includes('case_no')); + expect(seeds.length).toBeGreaterThan(0); + expect(seeds[0].where).toBeUndefined(); + expect(seeds[0].fields).toEqual(['id', 'case_no']); + }); + }); + + // --------------------------------------- (2) suffix AND prefix — same rule -- + + describe('`CASE-{000}-{YYYY}` — text on both sides of the slot', () => { + const SCHEMA = schemaWith('case_no', 'CASE-{000}-{YYYY}'); + + it('reads the counter between the prefix and the suffix', async () => { + const { result } = await insertOne( + SCHEMA, + storedRows('case_no', ['CASE-001-2026', 'CASE-002-2026', 'CASE-003-2026']), + ); + + expect(result.case_no).toBe('CASE-004-2026'); + }); + + it('still pushes the prefix down and still ignores foreign scopes', async () => { + const { result, driver } = await insertOne(SCHEMA, [ + ...storedRows('case_no', ['CASE-001-2026', 'CASE-002-2026']), + { id: rowId(50), case_no: 'OTHER-900-2026' }, + ]); + + expect(result.case_no).toBe('CASE-003-2026'); + const seeds = driver.queries.filter((q) => Array.isArray(q.fields) && q.fields.includes('case_no')); + expect(seeds[0].where).toEqual({ case_no: { $startsWith: 'CASE-' } }); + }); + }); + + // ------------------------------------------------ (3) controls — unchanged -- + + describe('formats with no suffix keep their existing behaviour', () => { + it('`D-{0000}` seeds from the digit run after the prefix', async () => { + const { result } = await insertOne( + schemaWith('doc_no', 'D-{0000}'), + storedRows('doc_no', ['D-0001', 'D-0002', 'D-0003']), + ); + + expect(result.doc_no).toBe('D-0004'); + }); + + it('`{0000}` seeds from the bare padded counter', async () => { + const { result } = await insertOne( + schemaWith('doc_no', '{0000}'), + storedRows('doc_no', ['0001', '0002', '0003']), + ); + + expect(result.doc_no).toBe('0004'); + }); + }); + + // ------------------------------------------- (4) legacy unanchored reading -- + + describe('a format declaring neither prefix nor suffix keeps the legacy reading', () => { + it('reads the LAST digit run of the whole value', async () => { + // No format at all: `renderAutonumber` emits the bare counter, and the + // stored values have no anchor. `'10'` must win over `'2'` — this is a + // numeric max, never a lexicographic one. + const { result } = await insertOne(schemaWith('ref_no'), storedRows('ref_no', ['1', '2', '10'])); + + expect(result.ref_no).toBe('11'); + }); + + it('reads past leading text that no format describes', async () => { + // Values written before any format existed: the unanchored reading takes + // the trailing run, which is what it has always done. + const { result } = await insertOne(schemaWith('ref_no'), storedRows('ref_no', ['SO-2024-0007'])); + + expect(result.ref_no).toBe('8'); + }); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7858da326e..747ad9b1cf 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -2130,7 +2130,7 @@ export class ObjectQL implements IObjectQLEngine { const probe = renderAutonumber({ tokens, seq: 0, record, now, timezone }); const counterKey = `${object}.${name}.${probe.scope}`; let next = this.autonumberCounters.get(counterKey); - if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx); + if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, probe.suffix, execCtx); next += 1; this.autonumberCounters.set(counterKey, next); record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value; @@ -2140,9 +2140,39 @@ export class ObjectQL implements IObjectQLEngine { /** * Seed the autonumber counter from the current max in store, scoped to * `prefix`. With a non-empty prefix (date/field formats) only rows in the - * same scope count, and the counter is the digit-run immediately after the - * prefix; with an empty prefix (legacy fixed-prefix formats) the last digit - * run of the whole value is used, preserving the original behaviour. + * same scope count. + * + * # Locating the counter inside a stored value (#6468) + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, and + * `suffix` is a DECLARED return value: every token after the `{0..0}` slot + * renders behind the counter (`{000}-{YYYY}` → `001-2026`). So the counter is + * NOT "the digits at the end of the string" — reading it that way took the + * year for the counter and seeded `2026` against a true counter of `1`, which + * jumps the next issued number to `2027-2026` and burns the band in between. + * + * Both the rendered `prefix` and the rendered `suffix` therefore come in from + * the caller (they are `renderAutonumber`'s own output — this method does not + * re-derive any format understanding of its own, and neither does the SQL + * driver's `scanMaxNumericTail`, which is handed the same two strings): + * + * - **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. + * - **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. + * + * 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 — here `''` + * — so those rows share this very counter and must still be counted. Skipping + * them would seed BELOW the real max, which is the duplicate-record-number + * harm #6249 fixed on the scan side. Reading the leading digit run gets them + * right regardless; the strip only adds precision when a suffix begins with a + * digit (`{0000}{YYYY}`, whose values are ambiguous by construction — the + * compile lint nudges authors to a delimiter). * * # Why this walks every row in the scope (#6249) * @@ -2183,6 +2213,7 @@ export class ObjectQL implements IObjectQLEngine { object: string, field: string, prefix: string, + suffix: string, execCtx?: ExecutionContext, ): Promise { try { @@ -2203,23 +2234,26 @@ export class ObjectQL implements IObjectQLEngine { }, ); let max = 0; + // Anchored when the format declares text on EITHER side of the slot; see + // the "Locating the counter" section above. + const anchored = prefix !== '' || suffix !== ''; for await (const page of walk.pages()) { for (const r of page) { const v = r?.[field]; if (v == null) continue; const s = String(v); if (prefix && !s.startsWith(prefix)) continue; - const tail = prefix ? s.slice(prefix.length) : s; - // With a prefix the counter is the digit run right after it; without one - // (legacy fixed-prefix formats) it is the LAST digit run. Both use the - // linear /\d+/g — a backtracking lookahead here is a polynomial-ReDoS - // sink on stored values full of zeros (CodeQL js/polynomial-redos). + // Both branches use the linear /\d+/ forms — a backtracking lookahead + // here is a polynomial-ReDoS sink on stored values full of zeros + // (CodeQL js/polynomial-redos). let digits: string | undefined; - if (prefix) { - const head = tail.match(/^\d+/); + if (anchored) { + let core = s.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 = tail.match(/\d+/g); + const runs = s.match(/\d+/g); digits = runs ? runs[runs.length - 1] : undefined; } if (digits) max = Math.max(max, parseInt(digits, 10) || 0); diff --git a/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts b/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts new file mode 100644 index 0000000000..c100a46b14 --- /dev/null +++ b/packages/runtime/src/autonumber-seed-cross-side-parity.integration.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6468 — the engine's fallback seeding and the SQL driver's sequence bootstrap + * must agree on the counter hiding in a stored record number. + * + * `renderAutonumber` composes `prefix + zero-padded(seq) + suffix`, so a format + * with tokens after the `{0..0}` slot (`{000}-{YYYY}` → `001-2026`) does not end + * in its counter. Both seeding paths used to assume it did, and each got it + * wrong its own way over rows numbered 1..3: + * + * - the engine took the LAST digit run — the year — and seeded `2026`, so the + * next number was `2027-2026`; + * - the SQL driver concatenated every digit of the tail and seeded `12026`, so + * the next number was `12027-2026`. + * + * Two different wrong answers for ONE dataset: the band a tenant received + * depended on which driver happened to be running, and numbers burned that way + * are not reclaimable. Fixing one side alone would have turned that into "one + * right, one wrong" — still cross-driver inconsistency, and harder to spot. + * + * These cases are the convergence assertion itself: one format, one set of + * stored rows, both real implementations, `toBe(sqlValue)` on the engine's + * answer. `packages/objectql` and `packages/drivers/driver-sql` each pin their + * own half; only this package can see both at once. + * + * The engine half runs on the REAL `InMemoryDriver` (`supports = {}`, so the + * engine's fallback owns the counter — the shape memory/mongo deployments run) + * and the driver half on a REAL `SqlDriver` over better-sqlite3, each holding + * the same fixture rows. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { SqlDriver } from '@objectstack/driver-sql'; + +/** Date tokens render from the wall clock, so the clock is pinned. Only `Date`. */ +const FIXED_NOW = new Date('2026-06-15T09:00:00Z'); + +/** One object, one text field, one autonumber field carrying `format`. */ +function recSchema(format: string) { + return { + name: 'rec', + fields: { + title: { type: 'text' }, + rec_no: { type: 'autonumber', format }, + }, + } as any; +} + +const legacyRows = (stored: string[]) => + stored.map((v, i) => ({ id: `l${i + 1}`, rec_no: v, title: `legacy ${i + 1}` })); + +/** The number the ENGINE's fallback seeding issues after `stored`. */ +async function engineIssues(format: string, stored: string[]): Promise { + const driver = new InMemoryDriver(); + const engine = new ObjectQL(); + engine.registerDriver(driver as any, true); + await engine.init(); + engine.registry.registerObject(recSchema(format)); + // Land the pre-existing numbers straight in the store: going through the + // engine would strip the caller-supplied autonumber values (#5503). + for (const row of legacyRows(stored)) await driver.create('rec', row); + + const created = await engine.insert('rec', { title: 'next' }); + await engine.destroy(); + return created.rec_no; +} + +/** The number the SQL DRIVER's sequence bootstrap issues after `stored`. */ +async function sqlDriverIssues(format: string, stored: string[]): Promise { + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await driver.initObjects([recSchema(format)]); + await (driver as any).knex('rec').insert(legacyRows(stored)); + + const created = await driver.create('rec', { title: 'next' }); + await driver.disconnect(); + return created.rec_no; +} + +interface Fixture { + label: string; + format: string; + stored: string[]; + /** The counter both sides must reach, rendered through the same format. */ + expected: string; + /** What each side issued before the fix — asserted absent, per side. */ + wasEngine?: string; + wasDriver?: string; +} + +const FIXTURES: Fixture[] = [ + { + label: '`{000}-{YYYY}` — the counter leads, the year trails', + format: '{000}-{YYYY}', + stored: ['001-2026', '002-2026', '003-2026'], + expected: '004-2026', + wasEngine: '2027-2026', + wasDriver: '12027-2026', + }, + { + label: '`CASE-{000}-{YYYY}` — text on both sides of the slot', + format: 'CASE-{000}-{YYYY}', + stored: ['CASE-001-2026', 'CASE-002-2026', 'CASE-003-2026'], + expected: 'CASE-004-2026', + // The engine already read this one correctly (a non-empty prefix anchored + // it); the driver still concatenated the tail into 12026. + wasDriver: 'CASE-12027-2026', + }, + { + label: '`D-{0000}` — no suffix (control: both sides were already right)', + format: 'D-{0000}', + stored: ['D-0001', 'D-0002', 'D-0003'], + expected: 'D-0004', + }, + { + label: '`{0000}` — bare slot (control: both sides were already right)', + format: '{0000}', + stored: ['0001', '0002', '0003'], + expected: '0004', + }, +]; + +describe('autonumber seeding parity — engine fallback vs SQL driver (#6468)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(FIXED_NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + for (const f of FIXTURES) { + it(`${f.label}: both sides issue ${f.expected}`, async () => { + const engineValue = await engineIssues(f.format, f.stored); + const sqlValue = await sqlDriverIssues(f.format, f.stored); + + // The convergence itself — one dataset, one answer, whichever driver runs. + expect(engineValue).toBe(sqlValue); + expect(engineValue).toBe(f.expected); + if (f.wasEngine) expect(engineValue).not.toBe(f.wasEngine); + if (f.wasDriver) expect(sqlValue).not.toBe(f.wasDriver); + }); + } + + it('rows carrying an older rendered suffix belong to the same counter on both sides', async () => { + // `{000}-{YYYY}` scopes its counter to the rendered PREFIX, which is '' — + // one counter, only the displayed year moves. Last year's `007-2025` holds + // counter 7, so both sides must continue at 8. A side that required the + // suffix to match would restart at 4 and re-issue numbers already stored. + const stored = ['005-2025', '006-2025', '007-2025', '003-2026']; + + const engineValue = await engineIssues('{000}-{YYYY}', stored); + const sqlValue = await sqlDriverIssues('{000}-{YYYY}', stored); + + expect(engineValue).toBe(sqlValue); + expect(engineValue).toBe('008-2026'); + }); +});