@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 +43 -8
- package/dist/index.d.ts +43 -8
- package/dist/index.js +118 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +118 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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.
|
|
388
|
-
|
|
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
|
-
|
|
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({ ...
|
|
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
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
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
|
-
|
|
597
|
+
probe.prefix,
|
|
501
598
|
cfg.tenantField,
|
|
502
599
|
tenantId,
|
|
503
|
-
parentTrx
|
|
600
|
+
parentTrx,
|
|
601
|
+
probe.scope
|
|
504
602
|
);
|
|
505
|
-
row[cfg.name] =
|
|
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
|
|
865
|
-
|
|
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
|
}
|