Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 30 additions & 0 deletions .changeset/autonumber-format-tokens.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/driver-sql": minor
---

feat(autonumber): date, {field} and per-scope counter reset for autonumber formats

`autonumberFormat` previously only understood a single `{0000}` sequence slot —
everything else was a fixed literal prefix on one global counter. Real MES/eHR
record numbers need three more token classes, so the format is now tokenized by a
shared pure renderer in `@objectstack/spec` (`parseAutonumberFormat` /
`renderAutonumber`) that the engine fallback and the SQL driver both 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 the new `DriverOptions.timezone`.
- **`{field}` interpolation** — `{section}{island_zone}{000}` substitutes record
field values into the prefix.
- **Per-scope counter reset** — the counter's scope is the rendered prefix *before*
the sequence slot, so `AD{YYYYMMDD}{0000}` resets daily, `{section}{island_zone}{000}`
numbers per group, and `{plan_no}{000}` numbers per parent — all from one
mechanism, no separate reset config.

Fixed-prefix formats like `CASE-{0000}` render an empty scope and keep their single
global counter, so existing sequences are unchanged. The persistent
`_objectstack_sequences` table gains a `scope` column (PK widened to
`object, tenant_id, field, scope`); deployments with the legacy 3-column table are
migrated in place on first use, carrying existing counters to `scope=''`.
39 changes: 39 additions & 0 deletions packages/objectql/src/engine-autonumber-defer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,43 @@ describe('ObjectQL autonumber ownership (#1603)', () => {
expect(driver.created[0].doc_no).toBe('D-0001');
expect(result.doc_no).toBe('D-0001');
});

// The fallback path renders the SAME format tokens as the SQL driver
// (shared @objectstack/spec renderer), so {field}/{date} grouping must match.
it('fallback renders {field} tokens and counts independently per scope', async () => {
const TASK_SCHEMA = {
name: 'task',
fields: {
zone: { type: 'text' },
task_no: { type: 'autonumber', format: '{zone}{000}' },
},
};
vi.mocked(SchemaRegistry.getObject).mockReturnValue(TASK_SCHEMA as any);
const driver = makeDriver(false);
engine.registerDriver(driver, true);
await engine.init();

const a1 = await engine.insert('task', { zone: 'A' });
const b1 = await engine.insert('task', { zone: 'B' });
const a2 = await engine.insert('task', { zone: 'A' });

expect(a1.task_no).toBe('A001');
expect(b1.task_no).toBe('B001'); // a different scope restarts at 001
expect(a2.task_no).toBe('A002');
});

it('fallback renders {YYYYMMDD} date tokens in the business timezone', async () => {
const AUDIT_SCHEMA = {
name: 'audit',
fields: { audit_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } },
};
vi.mocked(SchemaRegistry.getObject).mockReturnValue(AUDIT_SCHEMA as any);
const driver = makeDriver(false);
engine.registerDriver(driver, true);
await engine.init();

const r = await engine.insert('audit', {}, { timezone: 'UTC' } as any);
// Today's UTC day + a fresh per-day counter.
expect(r.audit_no).toMatch(/^AD\d{8}0001$/);
});
});
60 changes: 39 additions & 21 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
EngineAggregateOptions,
EngineCountOptions
} from '@objectstack/spec/data';
import { parseAutonumberFormat, renderAutonumber } from '@objectstack/spec/data';
import { ExecutionContext, ExecutionContextSchema } from '@objectstack/spec/kernel';
import { DriverInterface, IDataEngine, Logger, createLogger } from '@objectstack/core';
import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system';
Expand Down Expand Up @@ -699,15 +700,21 @@
: this.txStore.getStore()?.transaction;
const hasTx = tx !== undefined;
const hasTenant = execCtx?.tenantId !== undefined;
const hasTz = execCtx?.timezone !== undefined;
const isSystem = execCtx?.isSystem === true;
if (!hasTx && !hasTenant && !isSystem) return base;
if (!hasTx && !hasTenant && !isSystem && !hasTz) return base;
const opts: any = base && typeof base === 'object' ? { ...base } : {};
if (hasTx && opts.transaction === undefined) {
opts.transaction = tx;
}
if (hasTenant && opts.tenantId === undefined) {
opts.tenantId = execCtx!.tenantId;
}
if (hasTz && opts.timezone === undefined) {
// Thread the business timezone so date-dependent driver generation
// (autonumber `{YYYYMMDD}` tokens) resolves the calendar day correctly.
opts.timezone = execCtx!.timezone;
}
if (isSystem && opts.bypassTenantAudit === undefined) {
// System-elevated writes (boot-time seeds, internal mirrors, scheduled
// hooks) are unscoped by design — silence the audit warn for them but
Expand Down Expand Up @@ -793,9 +800,12 @@
* owns the value, not the client.
*
* In the fallback path the next value is `max(existing) + 1`, seeded once per
* `object.field` from the store then incremented in memory (monotonic within
* the process, resilient to deletions). `autonumberFormat` is honored, e.g.
* `CASE-{0000}` → `CASE-0042`. NOTE: this in-memory seeding is single-instance.
* `object.field.<scope>` from the store then incremented in memory (monotonic
* within the process, resilient to deletions). The shared `autonumberFormat`
* renderer is honored end-to-end, so date tokens (`AD{YYYYMMDD}{0000}`), field
* interpolation (`{island_zone}{000}`) and per-scope reset behave identically
* to the SQL driver's persistent sequence (#1603). NOTE: this in-memory seeding
* is single-instance.
*/
private async applyAutonumbers(
object: string,
Expand All @@ -806,26 +816,40 @@
if (driverOwnsAutonumber) return; // driver generates persistently in create()
const fields = (this.getSchema(object) as any)?.fields;
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return;
const now = new Date();
const timezone = execCtx?.timezone;
for (const [name, def] of Object.entries(fields)) {
if ((def as any)?.type !== 'autonumber') continue;
const current = record[name];
if (current != null && current !== '') continue; // respect explicit value
const key = `${object}.${name}`;
let next = this.autonumberCounters.get(key);
if (next == null) next = await this.seedAutonumber(object, name, execCtx);
next += 1;
this.autonumberCounters.set(key, next);
// Honor either the spec-canonical `autonumberFormat` or the shorthand
// `format` (both appear in metadata; the driver reads both too) — #1603.
const fmt = (def as any).autonumberFormat ?? (def as any).format;
record[name] = this.formatAutonumber(fmt, next);
const tokens = parseAutonumberFormat(typeof fmt === 'string' ? fmt : '');
// The counter scope is the rendered prefix (date/field tokens before the
// sequence slot); it is independent of the counter value, so a throwaway
// render with seq 0 yields the scope and the literal prefix to seed from.
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);
next += 1;
this.autonumberCounters.set(counterKey, next);
record[name] = renderAutonumber({ tokens, seq: next, record, now, timezone }).value;
}
}

/** Seed the autonumber counter from the current max numeric value in store. */
/**
* 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.
*/
private async seedAutonumber(
object: string,
field: string,
prefix: string,
execCtx?: ExecutionContext,
): Promise<number> {
try {
Expand All @@ -838,7 +862,10 @@
for (const r of rows || []) {
const v = r?.[field];
if (v == null) continue;
const m = String(v).match(/(\d+)(?!.*\d)/); // last run of digits
const s = String(v);
if (prefix && !s.startsWith(prefix)) continue;
const tail = prefix ? s.slice(prefix.length) : s;
const m = prefix ? tail.match(/^(\d+)/) : tail.match(/(\d+)(?!.*\d)/);

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '0'.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
if (m) max = Math.max(max, parseInt(m[1], 10) || 0);
}
return max;
Expand All @@ -847,15 +874,6 @@
}
}

/** Apply an autonumber format like `CASE-{0000}`; default to the bare number. */
private formatAutonumber(format: string | undefined, value: number): string {
if (!format) return String(value);
const m = format.match(/\{(0+)\}/);
if (!m) return format.includes('{0}') ? format.replace('{0}', String(value)) : `${format}${value}`;
const padded = String(value).padStart(m[1].length, '0');
return format.replace(m[0], padded);
}

/**
* Register contribution (Manifest)
*
Expand Down
165 changes: 165 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-autonumber-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

/**
* Auto-number format tokens — date interpolation, {field} interpolation, and
* per-scope counter reset. These are the MES/eHR record-number shapes
* (`AD{YYYYMMDD}{0000}`, `{section}{island_zone}{000}`, `{plan_no}{000}`) that
* the persistent `_objectstack_sequences` table now backs natively.
*/

/** Today's date in UTC as YYYYMMDD — matches the driver's renderer with tz=UTC. */
function utcYmd(): string {
const p = new Intl.DateTimeFormat('en-CA', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date());
const y = p.find((x) => x.type === 'year')!.value;
const m = p.find((x) => x.type === 'month')!.value;
const d = p.find((x) => x.type === 'day')!.value;
return `${y}${m}${d}`;
}

describe('SqlDriver auto_number format tokens', () => {
let driver: SqlDriver;

beforeEach(() => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
});

afterEach(async () => {
await driver.disconnect();
});

it('renders {YYYYMMDD} date tokens in the business timezone', async () => {
await driver.initObjects([
{ name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } },
]);
const r1 = await driver.create('andon', {}, { timezone: 'UTC' } as any);
const r2 = await driver.create('andon', {}, { timezone: 'UTC' } as any);
expect(r1.andon_no).toBe(`AD${utcYmd()}0001`);
expect(r2.andon_no).toBe(`AD${utcYmd()}0002`);
});

it('resets the counter per day (prior-day rows do not bleed into today)', async () => {
await driver.initObjects([
{ name: 'andon', fields: { andon_no: { type: 'autonumber', format: 'AD{YYYYMMDD}{0000}' } } },
]);
// A row from a long-past day sits at 0099. Today's counter must ignore it.
const k = (driver as any).knex;
await k('andon').insert({ id: 'old', andon_no: 'AD202001010099' });

const r = await driver.create('andon', {}, { timezone: 'UTC' } as any);
expect(r.andon_no).toBe(`AD${utcYmd()}0001`); // fresh scope, not 0100
});

it('interpolates {field} values and counts independently per group', async () => {
await driver.initObjects([
{
name: 'task',
fields: {
section: { type: 'string' },
island_zone: { type: 'string' },
task_no: { type: 'autonumber', format: '{section}{island_zone}{000}' },
},
},
]);
const a1 = await driver.create('task', { section: 'JYG', island_zone: '1A' });
const b1 = await driver.create('task', { section: 'JYG', island_zone: '2B' });
const a2 = await driver.create('task', { section: 'JYG', island_zone: '1A' });
const b2 = await driver.create('task', { section: 'JYG', island_zone: '2B' });

expect(a1.task_no).toBe('JYG1A001');
expect(a2.task_no).toBe('JYG1A002');
expect(b1.task_no).toBe('JYG2B001'); // a different island resets to 001
expect(b2.task_no).toBe('JYG2B002');
});

it('numbers child records per parent (dispatch order under a plan)', async () => {
await driver.initObjects([
{
name: 'dispatch_order',
fields: {
plan_no: { type: 'string' },
order_no: { type: 'autonumber', format: '{plan_no}{000}' },
},
},
]);
const p1a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' });
const p2a = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617002' });
const p1b = await driver.create('dispatch_order', { plan_no: 'JYG1A1PROD20260617001' });

expect(p1a.order_no).toBe('JYG1A1PROD20260617001001');
expect(p1b.order_no).toBe('JYG1A1PROD20260617001002');
expect(p2a.order_no).toBe('JYG1A1PROD20260617002001');
});

it('combines field + date tokens and stays tenant-isolated', async () => {
await driver.initObjects([
{
name: 'plan',
fields: {
organization_id: { type: 'string' },
line: { type: 'string' },
plan_no: { type: 'autonumber', format: '{line}{YYYYMMDD}{000}' },
},
},
]);
const a = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any);
const b = await driver.create('plan', { organization_id: 'orgB', line: 'PROD' }, { timezone: 'UTC' } as any);
const a2 = await driver.create('plan', { organization_id: 'orgA', line: 'PROD' }, { timezone: 'UTC' } as any);

expect(a.plan_no).toBe(`PROD${utcYmd()}001`);
expect(a2.plan_no).toBe(`PROD${utcYmd()}002`);
// Same scope string, different tenant → independent counter.
expect(b.plan_no).toBe(`PROD${utcYmd()}001`);
});

it('leaves fixed-prefix formats on a single global counter (backward compatible)', async () => {
await driver.initObjects([
{ name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } },
]);
const r1 = await driver.create('invoice', {});
const r2 = await driver.create('invoice', {});
expect(r1.invoice_number).toBe('INV-0001');
expect(r2.invoice_number).toBe('INV-0002');
});

it('migrates a legacy 3-column _objectstack_sequences table by adding scope', async () => {
const k = (driver as any).knex;
// Simulate a deployment whose sequence table predates the `scope` column.
await k.schema.createTable('_objectstack_sequences', (t: any) => {
t.string('object').notNullable();
t.string('tenant_id').notNullable();
t.string('field').notNullable();
t.bigInteger('last_value').notNullable().defaultTo(0);
t.timestamp('updated_at').defaultTo(k.fn.now());
t.primary(['object', 'tenant_id', 'field']);
});
await k('_objectstack_sequences').insert({
object: 'invoice',
tenant_id: '__global__',
field: 'invoice_number',
last_value: 41,
});

await driver.initObjects([
{ name: 'invoice', fields: { invoice_number: { type: 'autonumber', format: 'INV-{0000}' } } },
]);
// First create triggers the lazy table-ensure → in-place migration.
const r = await driver.create('invoice', {});

const cols = await k('_objectstack_sequences').columnInfo();
expect(Object.keys(cols)).toContain('scope');
// The legacy counter continued rather than restarting.
expect(r.invoice_number).toBe('INV-0042');
});
});
Loading
Loading