@objectstack/driver-sql 9.10.0 → 9.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -119,12 +119,19 @@ declare class SqlDriver implements IDataDriver {
119
119
  protected autoNumberFields: Record<string, Array<{
120
120
  name: string;
121
121
  format: string;
122
- prefix: string;
123
- padWidth: number;
122
+ tokens: AutonumberToken[];
124
123
  tenantField: string | null;
125
124
  }>>;
126
125
  /** Whether the sequences table has been ensured this process. */
127
126
  protected sequencesTableReady: boolean;
127
+ /**
128
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
129
+ * Set on a fresh create or a successful in-place migration. If a legacy table
130
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
131
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
132
+ * per-scope write raises an actionable error rather than corrupting counters.
133
+ */
134
+ protected sequencesHasKeyHash: boolean;
128
135
  /** In-flight ensure promise; deduplicates concurrent first calls. */
129
136
  protected sequencesTableEnsurePromise: Promise<void> | null;
130
137
  /**
@@ -214,8 +221,34 @@ declare class SqlDriver implements IDataDriver {
214
221
  /**
215
222
  * Ensure the sequence-counter table exists. Idempotent and cheap after
216
223
  * the first call (cached via `sequencesTableReady`).
224
+ *
225
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
226
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
227
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
228
+ * single 64-char hashed primary key (rather than the four raw columns, which
229
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
230
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
231
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
232
+ * keep their single global counter (backward compatible).
217
233
  */
218
234
  protected ensureSequencesTable(): Promise<void>;
235
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
236
+ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string;
237
+ /** Create the current `key_hash`-keyed sequences table shape. */
238
+ protected createSequencesTable(table: string): Promise<void>;
239
+ /**
240
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
241
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
242
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
243
+ * every legacy row is read, its `key_hash` computed in app code (no portable
244
+ * SQL hash exists), and re-inserted into a freshly built table that then
245
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
246
+ *
247
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
248
+ * sequences keep working via the legacy key and per-scope writes error
249
+ * actionably (see getNextSequenceValue), rather than corrupting data.
250
+ */
251
+ protected ensureSequencesKeyHashShape(): Promise<void>;
219
252
  /**
220
253
  * Bootstrap helper: scan the data table for the highest numeric suffix
221
254
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -237,12 +270,14 @@ declare class SqlDriver implements IDataDriver {
237
270
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
238
271
  * matching standard sequence semantics.
239
272
  */
240
- protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
273
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, scope?: string): Promise<number>;
241
274
  /**
242
- * For each `auto_number` field on the object that the caller did not
243
- * provide a value for, reserve the next sequence value scoped to the
244
- * record's tenant (or globally if the object has no tenant field) and
245
- * render `prefix + zero-padded(value)`.
275
+ * For each `auto_number` field the caller left empty, render the format and
276
+ * reserve the next counter value. The counter is scoped to the rendered
277
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
278
+ * plus `{field}` interpolation from the row), so it resets per period/group;
279
+ * the full rendered prefix bootstraps the counter from existing data, and the
280
+ * tenant scopes it for isolation.
246
281
  */
247
282
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
248
283
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -119,12 +119,19 @@ declare class SqlDriver implements IDataDriver {
119
119
  protected autoNumberFields: Record<string, Array<{
120
120
  name: string;
121
121
  format: string;
122
- prefix: string;
123
- padWidth: number;
122
+ tokens: AutonumberToken[];
124
123
  tenantField: string | null;
125
124
  }>>;
126
125
  /** Whether the sequences table has been ensured this process. */
127
126
  protected sequencesTableReady: boolean;
127
+ /**
128
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
129
+ * Set on a fresh create or a successful in-place migration. If a legacy table
130
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
131
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
132
+ * per-scope write raises an actionable error rather than corrupting counters.
133
+ */
134
+ protected sequencesHasKeyHash: boolean;
128
135
  /** In-flight ensure promise; deduplicates concurrent first calls. */
129
136
  protected sequencesTableEnsurePromise: Promise<void> | null;
130
137
  /**
@@ -214,8 +221,34 @@ declare class SqlDriver implements IDataDriver {
214
221
  /**
215
222
  * Ensure the sequence-counter table exists. Idempotent and cheap after
216
223
  * the first call (cached via `sequencesTableReady`).
224
+ *
225
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
226
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
227
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
228
+ * single 64-char hashed primary key (rather than the four raw columns, which
229
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
230
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
231
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
232
+ * keep their single global counter (backward compatible).
217
233
  */
218
234
  protected ensureSequencesTable(): Promise<void>;
235
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
236
+ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string;
237
+ /** Create the current `key_hash`-keyed sequences table shape. */
238
+ protected createSequencesTable(table: string): Promise<void>;
239
+ /**
240
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
241
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
242
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
243
+ * every legacy row is read, its `key_hash` computed in app code (no portable
244
+ * SQL hash exists), and re-inserted into a freshly built table that then
245
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
246
+ *
247
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
248
+ * sequences keep working via the legacy key and per-scope writes error
249
+ * actionably (see getNextSequenceValue), rather than corrupting data.
250
+ */
251
+ protected ensureSequencesKeyHashShape(): Promise<void>;
219
252
  /**
220
253
  * Bootstrap helper: scan the data table for the highest numeric suffix
221
254
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -237,12 +270,14 @@ declare class SqlDriver implements IDataDriver {
237
270
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
238
271
  * matching standard sequence semantics.
239
272
  */
240
- protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
273
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, scope?: string): Promise<number>;
241
274
  /**
242
- * For each `auto_number` field on the object that the caller did not
243
- * provide a value for, reserve the next sequence value scoped to the
244
- * record's tenant (or globally if the object has no tenant field) and
245
- * render `prefix + zero-padded(value)`.
275
+ * For each `auto_number` field the caller left empty, render the format and
276
+ * reserve the next counter value. The counter is scoped to the rendered
277
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
278
+ * plus `{field}` interpolation from the row), so it resets per period/group;
279
+ * the full rendered prefix bootstraps the counter from existing data, and the
280
+ * tenant scopes it for isolation.
246
281
  */
247
282
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
248
283
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  module.exports = __toCommonJS(index_exports);
37
37
 
38
38
  // src/sql-driver.ts
39
+ var import_data = require("@objectstack/spec/data");
39
40
  var import_system = require("@objectstack/spec/system");
40
41
  var import_shared = require("@objectstack/spec/shared");
41
42
  var import_knex = __toESM(require("knex"));
@@ -103,6 +104,14 @@ var SqlDriver = class {
103
104
  this.autoNumberFields = {};
104
105
  /** Whether the sequences table has been ensured this process. */
105
106
  this.sequencesTableReady = false;
107
+ /**
108
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
109
+ * Set on a fresh create or a successful in-place migration. If a legacy table
110
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
111
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
112
+ * per-scope write raises an actionable error rather than corrupting counters.
113
+ */
114
+ this.sequencesHasKeyHash = false;
106
115
  /** In-flight ensure promise; deduplicates concurrent first calls. */
107
116
  this.sequencesTableEnsurePromise = null;
108
117
  /**
@@ -410,6 +419,15 @@ var SqlDriver = class {
410
419
  /**
411
420
  * Ensure the sequence-counter table exists. Idempotent and cheap after
412
421
  * the first call (cached via `sequencesTableReady`).
422
+ *
423
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
424
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
425
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
426
+ * single 64-char hashed primary key (rather than the four raw columns, which
427
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
428
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
429
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
430
+ * keep their single global counter (backward compatible).
413
431
  */
414
432
  async ensureSequencesTable() {
415
433
  if (this.sequencesTableReady) return;
@@ -421,18 +439,15 @@ var SqlDriver = class {
421
439
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
422
440
  if (!exists) {
423
441
  try {
424
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
425
- t.string("object").notNullable();
426
- t.string("tenant_id").notNullable();
427
- t.string("field").notNullable();
428
- t.bigInteger("last_value").notNullable().defaultTo(0);
429
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
430
- t.primary(["object", "tenant_id", "field"]);
431
- });
442
+ await this.createSequencesTable(SEQUENCES_TABLE);
443
+ this.sequencesHasKeyHash = true;
432
444
  } catch (err) {
433
445
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
434
446
  if (stillMissing) throw err;
447
+ await this.ensureSequencesKeyHashShape();
435
448
  }
449
+ } else {
450
+ await this.ensureSequencesKeyHashShape();
436
451
  }
437
452
  this.sequencesTableReady = true;
438
453
  })();
@@ -442,6 +457,71 @@ var SqlDriver = class {
442
457
  this.sequencesTableEnsurePromise = null;
443
458
  }
444
459
  }
460
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
461
+ sequenceKeyHash(object, tenantId, field, scope) {
462
+ return (0, import_node_crypto.createHash)("sha256").update(`${object}${tenantId}${field}${scope}`).digest("hex");
463
+ }
464
+ /** Create the current `key_hash`-keyed sequences table shape. */
465
+ async createSequencesTable(table) {
466
+ await this.knex.schema.createTable(table, (t) => {
467
+ t.string("key_hash", 64).notNullable().primary();
468
+ t.string("object").notNullable();
469
+ t.string("tenant_id").notNullable();
470
+ t.string("field").notNullable();
471
+ t.string("scope", 1024).notNullable().defaultTo("");
472
+ t.bigInteger("last_value").notNullable().defaultTo(0);
473
+ t.timestamp("updated_at").defaultTo(this.knex.fn.now());
474
+ });
475
+ }
476
+ /**
477
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
478
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
479
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
480
+ * every legacy row is read, its `key_hash` computed in app code (no portable
481
+ * SQL hash exists), and re-inserted into a freshly built table that then
482
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
483
+ *
484
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
485
+ * sequences keep working via the legacy key and per-scope writes error
486
+ * actionably (see getNextSequenceValue), rather than corrupting data.
487
+ */
488
+ async ensureSequencesKeyHashShape() {
489
+ if (await this.knex.schema.hasColumn(SEQUENCES_TABLE, "key_hash")) {
490
+ this.sequencesHasKeyHash = true;
491
+ return;
492
+ }
493
+ const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, "scope");
494
+ const TMP = `${SEQUENCES_TABLE}__rebuild`;
495
+ try {
496
+ const rows = await this.knex(SEQUENCES_TABLE).select("*");
497
+ await this.knex.schema.dropTableIfExists(TMP);
498
+ await this.createSequencesTable(TMP);
499
+ const migrated = rows.map((r) => {
500
+ const scope = hasScope && r.scope != null ? String(r.scope) : "";
501
+ return {
502
+ key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope),
503
+ object: r.object,
504
+ tenant_id: r.tenant_id,
505
+ field: r.field,
506
+ scope,
507
+ last_value: r.last_value ?? 0,
508
+ updated_at: r.updated_at ?? this.knex.fn.now()
509
+ };
510
+ });
511
+ if (migrated.length > 0) await this.knex(TMP).insert(migrated);
512
+ await this.knex.schema.dropTable(SEQUENCES_TABLE);
513
+ await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE);
514
+ this.sequencesHasKeyHash = true;
515
+ } catch (err) {
516
+ this.sequencesHasKeyHash = false;
517
+ await this.knex.schema.dropTableIfExists(TMP).catch(() => {
518
+ });
519
+ this.logger.warn(
520
+ `[autonumber] Failed to migrate ${SEQUENCES_TABLE} to the key_hash shape. Fixed-prefix autonumbers keep working; date/{field}/per-parent formats will error until the table is migrated.`,
521
+ { error: String(err) }
522
+ );
523
+ }
524
+ }
445
525
  /**
446
526
  * Bootstrap helper: scan the data table for the highest numeric suffix
447
527
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -479,10 +559,16 @@ var SqlDriver = class {
479
559
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
480
560
  * matching standard sequence semantics.
481
561
  */
482
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
562
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
483
563
  await this.ensureSequencesTable();
484
564
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
485
- const key = { object: tableName, tenant_id: resolvedTenantId, field };
565
+ if (scope !== "" && !this.sequencesHasKeyHash) {
566
+ throw new Error(
567
+ `Cannot generate a per-scope autonumber for "${object}.${field}": the ${SEQUENCES_TABLE} table is still the legacy shape. Migrate it to the key_hash shape before using date/{field}/per-parent formats.`
568
+ );
569
+ }
570
+ const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) } : { object: tableName, tenant_id: resolvedTenantId, field };
571
+ const insertRow = this.sequencesHasKeyHash ? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope } : { ...key };
486
572
  const runner = parentTrx ?? this.knex;
487
573
  return runner.transaction(async (trx) => {
488
574
  let existing;
@@ -502,7 +588,7 @@ var SqlDriver = class {
502
588
  );
503
589
  const initial = seedMax + 1;
504
590
  try {
505
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
591
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
506
592
  return initial;
507
593
  } catch (err) {
508
594
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -515,31 +601,43 @@ var SqlDriver = class {
515
601
  });
516
602
  }
517
603
  /**
518
- * For each `auto_number` field on the object that the caller did not
519
- * provide a value for, reserve the next sequence value scoped to the
520
- * record's tenant (or globally if the object has no tenant field) and
521
- * render `prefix + zero-padded(value)`.
604
+ * For each `auto_number` field the caller left empty, render the format and
605
+ * reserve the next counter value. The counter is scoped to the rendered
606
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
607
+ * plus `{field}` interpolation from the row), so it resets per period/group;
608
+ * the full rendered prefix bootstraps the counter from existing data, and the
609
+ * tenant scopes it for isolation.
522
610
  */
523
611
  async fillAutoNumberFields(object, row, options) {
524
612
  const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
525
613
  const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
526
614
  if (!cfgs || cfgs.length === 0) return;
527
615
  const parentTrx = options?.transaction;
616
+ const timezone = options?.timezone;
617
+ const now = /* @__PURE__ */ new Date();
528
618
  for (const cfg of cfgs) {
529
619
  if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
620
+ const missing = (0, import_data.missingFieldValues)(cfg.tokens, row);
621
+ if (missing.length > 0) {
622
+ throw new Error(
623
+ `Cannot generate autonumber "${object}.${cfg.name}" (format "${cfg.format}"): referenced field(s) [${missing.join(", ")}] are empty on the record. Fields interpolated into an autonumber format must be set before the record is created.`
624
+ );
625
+ }
530
626
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
531
627
  const optTenant = options?.tenantId;
532
628
  const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
629
+ const probe = (0, import_data.renderAutonumber)({ tokens: cfg.tokens, seq: 0, record: row, now, timezone });
533
630
  const next = await this.getNextSequenceValue(
534
631
  object,
535
632
  tableName,
536
633
  cfg.name,
537
- cfg.prefix,
634
+ probe.prefix,
538
635
  cfg.tenantField,
539
636
  tenantId,
540
- parentTrx
637
+ parentTrx,
638
+ probe.scope
541
639
  );
542
- row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
640
+ row[cfg.name] = (0, import_data.renderAutonumber)({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
543
641
  }
544
642
  }
545
643
  async update(object, id, data, options) {
@@ -898,10 +996,8 @@ var SqlDriver = class {
898
996
  if (type === "auto_number" || type === "autonumber") {
899
997
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
900
998
  const fmt = rawFmt || "{0000}";
901
- const m = fmt.match(/\{(0+)\}/);
902
- const padWidth = m ? m[1].length : 4;
903
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
904
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
999
+ const tokens = (0, import_data.parseAutonumberFormat)(fmt);
1000
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
905
1001
  }
906
1002
  }
907
1003
  }