Skip to content

Commit 3d55c55

Browse files
committed
feat(autonumber): date, {field} and per-scope counter reset for formats
Tokenize autonumberFormat via a shared pure renderer in @objectstack/spec (parseAutonumberFormat / renderAutonumber) that both the engine fallback and the SQL driver call, so they emit byte-identical numbers (#1603 parity): - date tokens {YYYY}{YY}{MM}{DD}{YYYYMMDD} resolve the calendar day in the request's business timezone (ExecutionContext.timezone, ADR-0053; UTC fallback), threaded through new DriverOptions.timezone - {field} interpolation substitutes record values into the prefix - counter scope = rendered prefix before the sequence slot, so AD{YYYYMMDD}{0000} resets daily, {section}{island_zone}{000} numbers per group, {plan_no}{000} numbers per parent — one mechanism, no separate reset config Fixed-prefix formats (CASE-{0000}) render an empty scope and keep their single global counter. _objectstack_sequences gains a scope column (PK widened to object,tenant_id,field,scope); legacy 3-column tables migrate in place on first use, carrying existing counters to scope=''.
1 parent 94e9040 commit 3d55c55

10 files changed

Lines changed: 702 additions & 36 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/driver-sql": minor
5+
---
6+
7+
feat(autonumber): date, {field} and per-scope counter reset for autonumber formats
8+
9+
`autonumberFormat` previously only understood a single `{0000}` sequence slot —
10+
everything else was a fixed literal prefix on one global counter. Real MES/eHR
11+
record numbers need three more token classes, so the format is now tokenized by a
12+
shared pure renderer in `@objectstack/spec` (`parseAutonumberFormat` /
13+
`renderAutonumber`) that the engine fallback and the SQL driver both call, so they
14+
emit byte-identical numbers (#1603 parity):
15+
16+
- **Date tokens**`{YYYY}` `{YY}` `{MM}` `{DD}` `{YYYYMMDD}` resolve the calendar
17+
day in the request's **business timezone** (`ExecutionContext.timezone`, ADR-0053;
18+
UTC fallback), threaded through the new `DriverOptions.timezone`.
19+
- **`{field}` interpolation**`{section}{island_zone}{000}` substitutes record
20+
field values into the prefix.
21+
- **Per-scope counter reset** — the counter's scope is the rendered prefix *before*
22+
the sequence slot, so `AD{YYYYMMDD}{0000}` resets daily, `{section}{island_zone}{000}`
23+
numbers per group, and `{plan_no}{000}` numbers per parent — all from one
24+
mechanism, no separate reset config.
25+
26+
Fixed-prefix formats like `CASE-{0000}` render an empty scope and keep their single
27+
global counter, so existing sequences are unchanged. The persistent
28+
`_objectstack_sequences` table gains a `scope` column (PK widened to
29+
`object, tenant_id, field, scope`); deployments with the legacy 3-column table are
30+
migrated in place on first use, carrying existing counters to `scope=''`.

packages/objectql/src/engine-autonumber-defer.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,43 @@ describe('ObjectQL autonumber ownership (#1603)', () => {
112112
expect(driver.created[0].doc_no).toBe('D-0001');
113113
expect(result.doc_no).toBe('D-0001');
114114
});
115+
116+
// The fallback path renders the SAME format tokens as the SQL driver
117+
// (shared @objectstack/spec renderer), so {field}/{date} grouping must match.
118+
it('fallback renders {field} tokens and counts independently per scope', async () => {
119+
const TASK_SCHEMA = {
120+
name: 'task',
121+
fields: {
122+
zone: { type: 'text' },
123+
task_no: { type: 'autonumber', format: '{zone}{000}' },
124+
},
125+
};
126+
vi.mocked(SchemaRegistry.getObject).mockReturnValue(TASK_SCHEMA as any);
127+
const driver = makeDriver(false);
128+
engine.registerDriver(driver, true);
129+
await engine.init();
130+
131+
const a1 = await engine.insert('task', { zone: 'A' });
132+
const b1 = await engine.insert('task', { zone: 'B' });
133+
const a2 = await engine.insert('task', { zone: 'A' });
134+
135+
expect(a1.task_no).toBe('A001');
136+
expect(b1.task_no).toBe('B001'); // a different scope restarts at 001
137+
expect(a2.task_no).toBe('A002');
138+
});
139+
140+
it('fallback renders {YYYYMMDD} date tokens in the business timezone', async () => {
141+
const AUDIT_SCHEMA = {
142+
name: 'audit',
143+
fields: { audit_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } },
144+
};
145+
vi.mocked(SchemaRegistry.getObject).mockReturnValue(AUDIT_SCHEMA as any);
146+
const driver = makeDriver(false);
147+
engine.registerDriver(driver, true);
148+
await engine.init();
149+
150+
const r = await engine.insert('audit', {}, { timezone: 'UTC' } as any);
151+
// Today's UTC day + a fresh per-day counter.
152+
expect(r.audit_no).toMatch(/^AD\d{8}0001$/);
153+
});
115154
});

packages/objectql/src/engine.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
EngineAggregateOptions,
1111
EngineCountOptions
1212
} from '@objectstack/spec/data';
13+
import { parseAutonumberFormat, renderAutonumber } from '@objectstack/spec/data';
1314
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
1415
import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
1516
import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system';
@@ -699,15 +700,21 @@ export class ObjectQL implements IDataEngine {
699700
: this.txStore.getStore()?.transaction;
700701
const hasTx = tx !== undefined;
701702
const hasTenant = execCtx?.tenantId !== undefined;
703+
const hasTz = execCtx?.timezone !== undefined;
702704
const isSystem = execCtx?.isSystem === true;
703-
if (!hasTx && !hasTenant && !isSystem) return base;
705+
if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
704706
const opts: any = base && typeof base === 'object' ? { ...base } : {};
705707
if (hasTx && opts.transaction === undefined) {
706708
opts.transaction = tx;
707709
}
708710
if (hasTenant && opts.tenantId === undefined) {
709711
opts.tenantId = execCtx!.tenantId;
710712
}
713+
if (hasTz && opts.timezone === undefined) {
714+
// Thread the business timezone so date-dependent driver generation
715+
// (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly.
716+
opts.timezone = execCtx!.timezone;
717+
}
711718
if (isSystem && opts.bypassTenantAudit === undefined) {
712719
// System-elevated writes (boot-time seeds, internal mirrors, scheduled
713720
// hooks) are unscoped by design — silence the audit warn for them but
@@ -793,9 +800,12 @@ export class ObjectQL implements IDataEngine {
793800
* owns the value, not the client.
794801
*
795802
* In the fallback path the next value is `max(existing) + 1`, seeded once per
796-
* `object.field` from the store then incremented in memory (monotonic within
797-
* the process, resilient to deletions). `autonumberFormat` is honored, e.g.
798-
* `CASE-{0000}` → `CASE-0042`. NOTE: this in-memory seeding is single-instance.
803+
* `object.field.<scope>` from the store then incremented in memory (monotonic
804+
* within the process, resilient to deletions). The shared `autonumberFormat`
805+
* renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
806+
* interpolation (`{island_zone}{000}`) and per-scope reset behave identically
807+
* to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
808+
* is single-instance.
799809
*/
800810
private async applyAutonumbers(
801811
object: string,
@@ -806,26 +816,40 @@ export class ObjectQL implements IDataEngine {
806816
if (driverOwnsAutonumber) return; // driver generates persistently in create()
807817
const fields = (this.getSchema(object) as any)?.fields;
808818
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
819+
const now = new Date();
820+
const timezone = execCtx?.timezone;
809821
for (const [name, def] of Object.entries(fields)) {
810822
if ((def as any)?.type !== 'autonumber') continue;
811823
const current = record[name];
812824
if (current != null && current !== '') continue; // respect explicit value
813-
const key = `${object}.${name}`;
814-
let next = this.autonumberCounters.get(key);
815-
if (next == null) next = await this.seedAutonumber(object, name, execCtx);
816-
next += 1;
817-
this.autonumberCounters.set(key, next);
818825
// Honor either the spec-canonical `autonumberFormat` or the shorthand
819826
// `format` (both appear in metadata; the driver reads both too) — #1603.
820827
const fmt = (def as any).autonumberFormat ?? (def as any).format;
821-
record[name] = this.formatAutonumber(fmt, next);
828+
const tokens = parseAutonumberFormat(typeof fmt === 'string' ? fmt : '');
829+
// The counter scope is the rendered prefix (date/field tokens before the
830+
// sequence slot); it is independent of the counter value, so a throwaway
831+
// render with seq 0 yields the scope and the literal prefix to seed from.
832+
const probe = renderAutonumber({ tokens, seq: 0, record, now, timezone });
833+
const counterKey = `${object}.${name}.${probe.scope}`;
834+
let next = this.autonumberCounters.get(counterKey);
835+
if (next == null) next = await this.seedAutonumber(object, name, probe.prefix, execCtx);
836+
next += 1;
837+
this.autonumberCounters.set(counterKey, next);
838+
record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value;
822839
}
823840
}
824841

825-
/** Seed the autonumber counter from the current max numeric value in store. */
842+
/**
843+
* Seed the autonumber counter from the current max in store, scoped to
844+
* `prefix`. With a non-empty prefix (date/field formats) only rows in the
845+
* same scope count, and the counter is the digit-run immediately after the
846+
* prefix; with an empty prefix (legacy fixed-prefix formats) the last digit
847+
* run of the whole value is used, preserving the original behaviour.
848+
*/
826849
private async seedAutonumber(
827850
object: string,
828851
field: string,
852+
prefix: string,
829853
execCtx?: ExecutionContext,
830854
): Promise<number> {
831855
try {
@@ -838,7 +862,10 @@ export class ObjectQL implements IDataEngine {
838862
for (const r of rows || []) {
839863
const v = r?.[field];
840864
if (v == null) continue;
841-
const m = String(v).match(/(\d+)(?!.*\d)/); // last run of digits
865+
const s = String(v);
866+
if (prefix && !s.startsWith(prefix)) continue;
867+
const tail = prefix ? s.slice(prefix.length) : s;
868+
const m = prefix ? tail.match(/^(\d+)/) : tail.match(/(\d+)(?!.*\d)/);
842869
if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
843870
}
844871
return max;
@@ -847,15 +874,6 @@ export class ObjectQL implements IDataEngine {
847874
}
848875
}
849876

850-
/** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
851-
private formatAutonumber(format: string | undefined, value: number): string {
852-
if (!format) return String(value);
853-
const m = format.match(/\{(0+)\}/);
854-
if (!m) return format.includes('{0}') ? format.replace('{0}', String(value)) : `${format}${value}`;
855-
const padded = String(value).padStart(m[1].length, '0');
856-
return format.replace(m[0], padded);
857-
}
858-
859877
/**
860878
* Register contribution (Manifest)
861879
*
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { SqlDriver } from '../src/index.js';
5+
6+
/**
7+
* Auto-number format tokens — date interpolation, {field} interpolation, and
8+
* per-scope counter reset. These are the MES/eHR record-number shapes
9+
* (`AD{YYYYMMDD}{0000}`, `{section}{island_zone}{000}`, `{plan_no}{000}`) that
10+
* the persistent `_objectstack_sequences` table now backs natively.
11+
*/
12+
13+
/** Today's date in UTC as YYYYMMDD — matches the driver's renderer with tz=UTC. */
14+
function utcYmd(): string {
15+
const p = new Intl.DateTimeFormat('en-CA', {
16+
timeZone: 'UTC',
17+
year: 'numeric',
18+
month: '2-digit',
19+
day: '2-digit',
20+
}).formatToParts(new Date());
21+
const y = p.find((x) => x.type === 'year')!.value;
22+
const m = p.find((x) => x.type === 'month')!.value;
23+
const d = p.find((x) => x.type === 'day')!.value;
24+
return `${y}${m}${d}`;
25+
}
26+
27+
describe('SqlDriver auto_number format tokens', () => {
28+
let driver: SqlDriver;
29+
30+
beforeEach(() => {
31+
driver = new SqlDriver({
32+
client: 'better-sqlite3',
33+
connection: { filename: ':memory:' },
34+
useNullAsDefault: true,
35+
});
36+
});
37+
38+
afterEach(async () => {
39+
await driver.disconnect();
40+
});
41+
42+
it('renders {YYYYMMDD} date tokens in the business timezone', async () => {
43+
await driver.initObjects([
44+
{ name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } },
45+
]);
46+
const r1 = await driver.create('andon', {}, { timezone: 'UTC' } as any);
47+
const r2 = await driver.create('andon', {}, { timezone: 'UTC' } as any);
48+
expect(r1.andon_no).toBe(`AD${utcYmd()}0001`);
49+
expect(r2.andon_no).toBe(`AD${utcYmd()}0002`);
50+
});
51+
52+
it('resets the counter per day (prior-day rows do not bleed into today)', async () => {
53+
await driver.initObjects([
54+
{ name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } },
55+
]);
56+
// A row from a long-past day sits at 0099. Today's counter must ignore it.
57+
const k = (driver as any).knex;
58+
await k('andon').insert({ id: 'old', andon_no: 'AD202001010099' });
59+
60+
const r = await driver.create('andon', {}, { timezone: 'UTC' } as any);
61+
expect(r.andon_no).toBe(`AD${utcYmd()}0001`); // fresh scope, not 0100
62+
});
63+
64+
it('interpolates {field} values and counts independently per group', async () => {
65+
await driver.initObjects([
66+
{
67+
name: 'task',
68+
fields: {
69+
section: { type: 'string' },
70+
island_zone: { type: 'string' },
71+
task_no: { type: 'autonumber', format: '{section}{island_zone}{000}' },
72+
},
73+
},
74+
]);
75+
const a1 = await driver.create('task', { section: 'JYG', island_zone: '1A' });
76+
const b1 = await driver.create('task', { section: 'JYG', island_zone: '2B' });
77+
const a2 = await driver.create('task', { section: 'JYG', island_zone: '1A' });
78+
const b2 = await driver.create('task', { section: 'JYG', island_zone: '2B' });
79+
80+
expect(a1.task_no).toBe('JYG1A001');
81+
expect(a2.task_no).toBe('JYG1A002');
82+
expect(b1.task_no).toBe('JYG2B001'); // a different island resets to 001
83+
expect(b2.task_no).toBe('JYG2B002');
84+
});
85+
86+
it('numbers child records per parent (dispatch order under a plan)', async () => {
87+
await driver.initObjects([
88+
{
89+
name: 'dispatch_order',
90+
fields: {
91+
plan_no: { type: 'string' },
92+
order_no: { type: 'autonumber', format: '{plan_no}{000}' },
93+
},
94+
},
95+
]);
96+
const p1a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' });
97+
const p2a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617002' });
98+
const p1b = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' });
99+
100+
expect(p1a.order_no).toBe('JYG1A1PROD20260617001001');
101+
expect(p1b.order_no).toBe('JYG1A1PROD20260617001002');
102+
expect(p2a.order_no).toBe('JYG1A1PROD20260617002001');
103+
});
104+
105+
it('combines field + date tokens and stays tenant-isolated', async () => {
106+
await driver.initObjects([
107+
{
108+
name: 'plan',
109+
fields: {
110+
organization_id: { type: 'string' },
111+
line: { type: 'string' },
112+
plan_no: { type: 'autonumber', format: '{line}{YYYYMMDD}{000}' },
113+
},
114+
},
115+
]);
116+
const a = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any);
117+
const b = await driver.create('plan', { organization_id: 'orgB', line: 'PROD' }, { timezone: 'UTC' } as any);
118+
const a2 = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any);
119+
120+
expect(a.plan_no).toBe(`PROD${utcYmd()}001`);
121+
expect(a2.plan_no).toBe(`PROD${utcYmd()}002`);
122+
// Same scope string, different tenant → independent counter.
123+
expect(b.plan_no).toBe(`PROD${utcYmd()}001`);
124+
});
125+
126+
it('leaves fixed-prefix formats on a single global counter (backward compatible)', async () => {
127+
await driver.initObjects([
128+
{ name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } },
129+
]);
130+
const r1 = await driver.create('invoice', {});
131+
const r2 = await driver.create('invoice', {});
132+
expect(r1.invoice_number).toBe('INV-0001');
133+
expect(r2.invoice_number).toBe('INV-0002');
134+
});
135+
136+
it('migrates a legacy 3-column _objectstack_sequences table by adding scope', async () => {
137+
const k = (driver as any).knex;
138+
// Simulate a deployment whose sequence table predates the `scope` column.
139+
await k.schema.createTable('_objectstack_sequences', (t: any) => {
140+
t.string('object').notNullable();
141+
t.string('tenant_id').notNullable();
142+
t.string('field').notNullable();
143+
t.bigInteger('last_value').notNullable().defaultTo(0);
144+
t.timestamp('updated_at').defaultTo(k.fn.now());
145+
t.primary(['object', 'tenant_id', 'field']);
146+
});
147+
await k('_objectstack_sequences').insert({
148+
object: 'invoice',
149+
tenant_id: '__global__',
150+
field: 'invoice_number',
151+
last_value: 41,
152+
});
153+
154+
await driver.initObjects([
155+
{ name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } },
156+
]);
157+
// First create triggers the lazy table-ensure → in-place migration.
158+
const r = await driver.create('invoice', {});
159+
160+
const cols = await k('_objectstack_sequences').columnInfo();
161+
expect(Object.keys(cols)).toContain('scope');
162+
// The legacy counter continued rather than restarting.
163+
expect(r.invoice_number).toBe('INV-0042');
164+
});
165+
});

0 commit comments

Comments
 (0)