@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.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/sql-driver.ts
2
+ import { parseAutonumberFormat, renderAutonumber, missingFieldValues } from "@objectstack/spec/data";
2
3
  import { StorageNameMapping } from "@objectstack/spec/system";
3
4
  import { ExternalSchemaModeViolationError } from "@objectstack/spec/shared";
4
5
  import knex from "knex";
@@ -66,6 +67,14 @@ var SqlDriver = class {
66
67
  this.autoNumberFields = {};
67
68
  /** Whether the sequences table has been ensured this process. */
68
69
  this.sequencesTableReady = false;
70
+ /**
71
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
72
+ * Set on a fresh create or a successful in-place migration. If a legacy table
73
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
74
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
75
+ * per-scope write raises an actionable error rather than corrupting counters.
76
+ */
77
+ this.sequencesHasKeyHash = false;
69
78
  /** In-flight ensure promise; deduplicates concurrent first calls. */
70
79
  this.sequencesTableEnsurePromise = null;
71
80
  /**
@@ -373,6 +382,15 @@ var SqlDriver = class {
373
382
  /**
374
383
  * Ensure the sequence-counter table exists. Idempotent and cheap after
375
384
  * the first call (cached via `sequencesTableReady`).
385
+ *
386
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
387
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
388
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
389
+ * single 64-char hashed primary key (rather than the four raw columns, which
390
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
391
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
392
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
393
+ * keep their single global counter (backward compatible).
376
394
  */
377
395
  async ensureSequencesTable() {
378
396
  if (this.sequencesTableReady) return;
@@ -384,18 +402,15 @@ var SqlDriver = class {
384
402
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
385
403
  if (!exists) {
386
404
  try {
387
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
388
- t.string("object").notNullable();
389
- t.string("tenant_id").notNullable();
390
- t.string("field").notNullable();
391
- t.bigInteger("last_value").notNullable().defaultTo(0);
392
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
393
- t.primary(["object", "tenant_id", "field"]);
394
- });
405
+ await this.createSequencesTable(SEQUENCES_TABLE);
406
+ this.sequencesHasKeyHash = true;
395
407
  } catch (err) {
396
408
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
397
409
  if (stillMissing) throw err;
410
+ await this.ensureSequencesKeyHashShape();
398
411
  }
412
+ } else {
413
+ await this.ensureSequencesKeyHashShape();
399
414
  }
400
415
  this.sequencesTableReady = true;
401
416
  })();
@@ -405,6 +420,71 @@ var SqlDriver = class {
405
420
  this.sequencesTableEnsurePromise = null;
406
421
  }
407
422
  }
423
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
424
+ sequenceKeyHash(object, tenantId, field, scope) {
425
+ return createHash("sha256").update(`${object}${tenantId}${field}${scope}`).digest("hex");
426
+ }
427
+ /** Create the current `key_hash`-keyed sequences table shape. */
428
+ async createSequencesTable(table) {
429
+ await this.knex.schema.createTable(table, (t) => {
430
+ t.string("key_hash", 64).notNullable().primary();
431
+ t.string("object").notNullable();
432
+ t.string("tenant_id").notNullable();
433
+ t.string("field").notNullable();
434
+ t.string("scope", 1024).notNullable().defaultTo("");
435
+ t.bigInteger("last_value").notNullable().defaultTo(0);
436
+ t.timestamp("updated_at").defaultTo(this.knex.fn.now());
437
+ });
438
+ }
439
+ /**
440
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
441
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
442
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
443
+ * every legacy row is read, its `key_hash` computed in app code (no portable
444
+ * SQL hash exists), and re-inserted into a freshly built table that then
445
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
446
+ *
447
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
448
+ * sequences keep working via the legacy key and per-scope writes error
449
+ * actionably (see getNextSequenceValue), rather than corrupting data.
450
+ */
451
+ async ensureSequencesKeyHashShape() {
452
+ if (await this.knex.schema.hasColumn(SEQUENCES_TABLE, "key_hash")) {
453
+ this.sequencesHasKeyHash = true;
454
+ return;
455
+ }
456
+ const hasScope = await this.knex.schema.hasColumn(SEQUENCES_TABLE, "scope");
457
+ const TMP = `${SEQUENCES_TABLE}__rebuild`;
458
+ try {
459
+ const rows = await this.knex(SEQUENCES_TABLE).select("*");
460
+ await this.knex.schema.dropTableIfExists(TMP);
461
+ await this.createSequencesTable(TMP);
462
+ const migrated = rows.map((r) => {
463
+ const scope = hasScope && r.scope != null ? String(r.scope) : "";
464
+ return {
465
+ key_hash: this.sequenceKeyHash(String(r.object), String(r.tenant_id), String(r.field), scope),
466
+ object: r.object,
467
+ tenant_id: r.tenant_id,
468
+ field: r.field,
469
+ scope,
470
+ last_value: r.last_value ?? 0,
471
+ updated_at: r.updated_at ?? this.knex.fn.now()
472
+ };
473
+ });
474
+ if (migrated.length > 0) await this.knex(TMP).insert(migrated);
475
+ await this.knex.schema.dropTable(SEQUENCES_TABLE);
476
+ await this.knex.schema.renameTable(TMP, SEQUENCES_TABLE);
477
+ this.sequencesHasKeyHash = true;
478
+ } catch (err) {
479
+ this.sequencesHasKeyHash = false;
480
+ await this.knex.schema.dropTableIfExists(TMP).catch(() => {
481
+ });
482
+ this.logger.warn(
483
+ `[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.`,
484
+ { error: String(err) }
485
+ );
486
+ }
487
+ }
408
488
  /**
409
489
  * Bootstrap helper: scan the data table for the highest numeric suffix
410
490
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -442,10 +522,16 @@ var SqlDriver = class {
442
522
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
443
523
  * matching standard sequence semantics.
444
524
  */
445
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
525
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
446
526
  await this.ensureSequencesTable();
447
527
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
448
- const key = { object: tableName, tenant_id: resolvedTenantId, field };
528
+ if (scope !== "" && !this.sequencesHasKeyHash) {
529
+ throw new Error(
530
+ `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.`
531
+ );
532
+ }
533
+ const key = this.sequencesHasKeyHash ? { key_hash: this.sequenceKeyHash(tableName, resolvedTenantId, field, scope) } : { object: tableName, tenant_id: resolvedTenantId, field };
534
+ const insertRow = this.sequencesHasKeyHash ? { ...key, object: tableName, tenant_id: resolvedTenantId, field, scope } : { ...key };
449
535
  const runner = parentTrx ?? this.knex;
450
536
  return runner.transaction(async (trx) => {
451
537
  let existing;
@@ -465,7 +551,7 @@ var SqlDriver = class {
465
551
  );
466
552
  const initial = seedMax + 1;
467
553
  try {
468
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
554
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
469
555
  return initial;
470
556
  } catch (err) {
471
557
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -478,31 +564,43 @@ var SqlDriver = class {
478
564
  });
479
565
  }
480
566
  /**
481
- * For each `auto_number` field on the object that the caller did not
482
- * provide a value for, reserve the next sequence value scoped to the
483
- * record's tenant (or globally if the object has no tenant field) and
484
- * render `prefix + zero-padded(value)`.
567
+ * For each `auto_number` field the caller left empty, render the format and
568
+ * reserve the next counter value. The counter is scoped to the rendered
569
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
570
+ * plus `{field}` interpolation from the row), so it resets per period/group;
571
+ * the full rendered prefix bootstraps the counter from existing data, and the
572
+ * tenant scopes it for isolation.
485
573
  */
486
574
  async fillAutoNumberFields(object, row, options) {
487
575
  const tableName = StorageNameMapping.resolveTableName({ name: object });
488
576
  const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
489
577
  if (!cfgs || cfgs.length === 0) return;
490
578
  const parentTrx = options?.transaction;
579
+ const timezone = options?.timezone;
580
+ const now = /* @__PURE__ */ new Date();
491
581
  for (const cfg of cfgs) {
492
582
  if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
583
+ const missing = missingFieldValues(cfg.tokens, row);
584
+ if (missing.length > 0) {
585
+ throw new Error(
586
+ `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.`
587
+ );
588
+ }
493
589
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
494
590
  const optTenant = options?.tenantId;
495
591
  const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
592
+ const probe = renderAutonumber({ tokens: cfg.tokens, seq: 0, record: row, now, timezone });
496
593
  const next = await this.getNextSequenceValue(
497
594
  object,
498
595
  tableName,
499
596
  cfg.name,
500
- cfg.prefix,
597
+ probe.prefix,
501
598
  cfg.tenantField,
502
599
  tenantId,
503
- parentTrx
600
+ parentTrx,
601
+ probe.scope
504
602
  );
505
- row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
603
+ row[cfg.name] = renderAutonumber({ tokens: cfg.tokens, seq: next, record: row, now, timezone }).value;
506
604
  }
507
605
  }
508
606
  async update(object, id, data, options) {
@@ -861,10 +959,8 @@ var SqlDriver = class {
861
959
  if (type === "auto_number" || type === "autonumber") {
862
960
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
863
961
  const fmt = rawFmt || "{0000}";
864
- const m = fmt.match(/\{(0+)\}/);
865
- const padWidth = m ? m[1].length : 4;
866
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
867
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
962
+ const tokens = parseAutonumberFormat(fmt);
963
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
868
964
  }
869
965
  }
870
966
  }