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
41 changes: 41 additions & 0 deletions .changeset/autonumber-seed-suffix-parse.md
Original file line number Diff line number Diff line change
@@ -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 的
播种扫描结构未触碰。
191 changes: 191 additions & 0 deletions packages/drivers/driver-sql/src/sql-driver-autonumber-suffix.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
61 changes: 56 additions & 5 deletions packages/drivers/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3056,9 +3056,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,
Expand All @@ -3067,6 +3097,7 @@ export class SqlDriver implements IDataDriver {
prefix: string,
tenantField: string | null,
tenantId: string | null,
suffix = '',
): Promise<number> {
const escapedPrefix = prefix.replace(/([\\%_])/g, '\\$1');
let builder = queryRunner(tableName).select(field).where(field, 'like', `${escapedPrefix}%`).whereNotNull(field);
Expand All @@ -3075,11 +3106,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;
Expand Down Expand Up @@ -3109,6 +3154,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<number> {
// 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-
Expand Down Expand Up @@ -3161,6 +3210,7 @@ export class SqlDriver implements IDataDriver {
prefix,
tenantField,
resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId,
suffix,
);
const initial = seedMax + 1;
try {
Expand Down Expand Up @@ -3237,6 +3287,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;
}
Expand Down
Loading
Loading