@objectstack/driver-sql 9.9.1 → 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";
@@ -11,9 +12,12 @@ var JSON_COLUMN_TYPES = /* @__PURE__ */ new Set([
11
12
  "json",
12
13
  "object",
13
14
  "array",
15
+ "record",
14
16
  "image",
15
17
  "file",
16
18
  "avatar",
19
+ "video",
20
+ "audio",
17
21
  "location",
18
22
  "address",
19
23
  "composite",
@@ -23,6 +27,18 @@ var JSON_COLUMN_TYPES = /* @__PURE__ */ new Set([
23
27
  "repeater",
24
28
  "vector"
25
29
  ]);
30
+ var NUMERIC_SCALAR_TYPES = /* @__PURE__ */ new Set([
31
+ "integer",
32
+ "int",
33
+ "float",
34
+ "number",
35
+ "currency",
36
+ "percent",
37
+ "summary",
38
+ "rating",
39
+ "slider",
40
+ "progress"
41
+ ]);
26
42
  var SqlDriver = class {
27
43
  constructor(config) {
28
44
  // IDataDriver metadata
@@ -30,6 +46,7 @@ var SqlDriver = class {
30
46
  this.version = "1.0.0";
31
47
  this.jsonFields = {};
32
48
  this.booleanFields = {};
49
+ this.numericFields = {};
33
50
  this.dateFields = {};
34
51
  this.datetimeFields = {};
35
52
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
@@ -50,6 +67,14 @@ var SqlDriver = class {
50
67
  this.autoNumberFields = {};
51
68
  /** Whether the sequences table has been ensured this process. */
52
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;
53
78
  /** In-flight ensure promise; deduplicates concurrent first calls. */
54
79
  this.sequencesTableEnsurePromise = null;
55
80
  /**
@@ -357,6 +382,15 @@ var SqlDriver = class {
357
382
  /**
358
383
  * Ensure the sequence-counter table exists. Idempotent and cheap after
359
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).
360
394
  */
361
395
  async ensureSequencesTable() {
362
396
  if (this.sequencesTableReady) return;
@@ -368,18 +402,15 @@ var SqlDriver = class {
368
402
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
369
403
  if (!exists) {
370
404
  try {
371
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
372
- t.string("object").notNullable();
373
- t.string("tenant_id").notNullable();
374
- t.string("field").notNullable();
375
- t.bigInteger("last_value").notNullable().defaultTo(0);
376
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
377
- t.primary(["object", "tenant_id", "field"]);
378
- });
405
+ await this.createSequencesTable(SEQUENCES_TABLE);
406
+ this.sequencesHasKeyHash = true;
379
407
  } catch (err) {
380
408
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
381
409
  if (stillMissing) throw err;
410
+ await this.ensureSequencesKeyHashShape();
382
411
  }
412
+ } else {
413
+ await this.ensureSequencesKeyHashShape();
383
414
  }
384
415
  this.sequencesTableReady = true;
385
416
  })();
@@ -389,6 +420,71 @@ var SqlDriver = class {
389
420
  this.sequencesTableEnsurePromise = null;
390
421
  }
391
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
+ }
392
488
  /**
393
489
  * Bootstrap helper: scan the data table for the highest numeric suffix
394
490
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -426,10 +522,16 @@ var SqlDriver = class {
426
522
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
427
523
  * matching standard sequence semantics.
428
524
  */
429
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
525
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
430
526
  await this.ensureSequencesTable();
431
527
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
432
- 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 };
433
535
  const runner = parentTrx ?? this.knex;
434
536
  return runner.transaction(async (trx) => {
435
537
  let existing;
@@ -449,7 +551,7 @@ var SqlDriver = class {
449
551
  );
450
552
  const initial = seedMax + 1;
451
553
  try {
452
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
554
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
453
555
  return initial;
454
556
  } catch (err) {
455
557
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -462,31 +564,43 @@ var SqlDriver = class {
462
564
  });
463
565
  }
464
566
  /**
465
- * For each `auto_number` field on the object that the caller did not
466
- * provide a value for, reserve the next sequence value scoped to the
467
- * record's tenant (or globally if the object has no tenant field) and
468
- * 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.
469
573
  */
470
574
  async fillAutoNumberFields(object, row, options) {
471
575
  const tableName = StorageNameMapping.resolveTableName({ name: object });
472
576
  const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
473
577
  if (!cfgs || cfgs.length === 0) return;
474
578
  const parentTrx = options?.transaction;
579
+ const timezone = options?.timezone;
580
+ const now = /* @__PURE__ */ new Date();
475
581
  for (const cfg of cfgs) {
476
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
+ }
477
589
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
478
590
  const optTenant = options?.tenantId;
479
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 });
480
593
  const next = await this.getNextSequenceValue(
481
594
  object,
482
595
  tableName,
483
596
  cfg.name,
484
- cfg.prefix,
597
+ probe.prefix,
485
598
  cfg.tenantField,
486
599
  tenantId,
487
- parentTrx
600
+ parentTrx,
601
+ probe.scope
488
602
  );
489
- 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;
490
604
  }
491
605
  }
492
606
  async update(object, id, data, options) {
@@ -810,6 +924,7 @@ var SqlDriver = class {
810
924
  const tableName = StorageNameMapping.resolveTableName(obj);
811
925
  const jsonCols = [];
812
926
  const booleanCols = [];
927
+ const numericCols = [];
813
928
  const autoNumberCols = [];
814
929
  const tenancyDecl = obj?.tenancy;
815
930
  let tenantField = null;
@@ -829,9 +944,12 @@ var SqlDriver = class {
829
944
  if (this.isJsonField(type, field)) {
830
945
  jsonCols.push(name);
831
946
  }
832
- if (type === "boolean") {
947
+ if (type === "boolean" || type === "toggle") {
833
948
  booleanCols.push(name);
834
949
  }
950
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) {
951
+ numericCols.push(name);
952
+ }
835
953
  if (type === "date") {
836
954
  ((_a = this.dateFields)[tableName] ?? (_a[tableName] = /* @__PURE__ */ new Set())).add(name);
837
955
  }
@@ -841,15 +959,14 @@ var SqlDriver = class {
841
959
  if (type === "auto_number" || type === "autonumber") {
842
960
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
843
961
  const fmt = rawFmt || "{0000}";
844
- const m = fmt.match(/\{(0+)\}/);
845
- const padWidth = m ? m[1].length : 4;
846
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
847
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
962
+ const tokens = parseAutonumberFormat(fmt);
963
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
848
964
  }
849
965
  }
850
966
  }
851
967
  this.jsonFields[tableName] = jsonCols;
852
968
  this.booleanFields[tableName] = booleanCols;
969
+ this.numericFields[tableName] = numericCols;
853
970
  this.autoNumberFields[tableName] = autoNumberCols;
854
971
  this.tenantFieldByTable[tableName] = tenantField;
855
972
  let exists = await this.knex.schema.hasTable(tableName);
@@ -1201,11 +1318,35 @@ var SqlDriver = class {
1201
1318
  return null;
1202
1319
  };
1203
1320
  if (isDatetime) {
1321
+ if (!this.isSqlite) return value;
1204
1322
  const ms = toMs(value);
1205
1323
  return ms == null ? value : ms;
1206
1324
  }
1207
1325
  return this.toDateOnly(value);
1208
1326
  }
1327
+ /**
1328
+ * Public, dialect-correct temporal filter-value coercion for callers that
1329
+ * build SQL *outside* the normal `find()`/`applyFilters()` path — chiefly the
1330
+ * analytics native-SQL strategy, which compiles a raw `SELECT … WHERE col >= $N`
1331
+ * and binds the value directly, bypassing `coerceFilterValue`.
1332
+ *
1333
+ * Given a logical object (table) name, a field name and a filter value
1334
+ * (typically an ISO date/datetime string from a dashboard relative-date
1335
+ * token like `{12_months_ago}`), this returns the value in the column's
1336
+ * on-disk storage form:
1337
+ * - SQLite `Field.datetime` → epoch milliseconds (INTEGER), so the
1338
+ * comparison matches the stored integer rather than failing a
1339
+ * TEXT-vs-INTEGER affinity compare.
1340
+ * - `Field.date` (any dialect) → `YYYY-MM-DD` text.
1341
+ * - Native-timestamp dialects / non-temporal fields → value unchanged.
1342
+ *
1343
+ * This is a thin, intentionally narrow wrapper over the same `coerceFilterValue`
1344
+ * the driver already uses, so there is exactly one source of truth for the
1345
+ * storage convention and the analytics path can never drift from CRUD.
1346
+ */
1347
+ temporalFilterValue(objectName, field, value) {
1348
+ return this.coerceFilterValue(objectName, field, value);
1349
+ }
1209
1350
  applyFilters(builder, filters) {
1210
1351
  if (!filters) return;
1211
1352
  const table = this.tableNameForBuilder(builder);
@@ -1427,9 +1568,23 @@ var SqlDriver = class {
1427
1568
  case "number":
1428
1569
  case "currency":
1429
1570
  case "percent":
1571
+ // `rating`/`slider`/`progress` are authored as numeric scalars (a star
1572
+ // count, a slider position, a percent-of-completion). Without an explicit
1573
+ // case they fell to `default → table.string`, giving the column TEXT
1574
+ // affinity so SQLite coerced the written number to a string ('4' not 4) —
1575
+ // a silent type-fidelity leak the value-loss tests didn't catch. REAL
1576
+ // affinity round-trips them as JS numbers (#field-zoo).
1577
+ case "rating":
1578
+ case "slider":
1579
+ case "progress":
1430
1580
  col = table.float(name);
1431
1581
  break;
1582
+ // `toggle` is a boolean rendered as a switch. Same leak as above (TEXT
1583
+ // affinity stored '1'); a boolean column gives NUMERIC affinity and the
1584
+ // `booleanFields` read-coercion below converts the stored 1/0 back to a
1585
+ // real JS boolean.
1432
1586
  case "boolean":
1587
+ case "toggle":
1433
1588
  col = table.boolean(name);
1434
1589
  break;
1435
1590
  case "date":
@@ -1441,22 +1596,6 @@ var SqlDriver = class {
1441
1596
  case "time":
1442
1597
  col = table.time(name);
1443
1598
  break;
1444
- case "json":
1445
- case "object":
1446
- case "array":
1447
- case "image":
1448
- case "file":
1449
- case "avatar":
1450
- case "location":
1451
- case "address":
1452
- case "composite":
1453
- case "multiselect":
1454
- case "checkboxes":
1455
- case "tags":
1456
- case "repeater":
1457
- case "vector":
1458
- col = table.json(name);
1459
- break;
1460
1599
  case "lookup":
1461
1600
  col = table.string(name);
1462
1601
  if (field.reference_to) {
@@ -1474,7 +1613,7 @@ var SqlDriver = class {
1474
1613
  return;
1475
1614
  // Virtual — no column
1476
1615
  default:
1477
- col = table.string(name);
1616
+ col = JSON_COLUMN_TYPES.has(type) ? table.json(name) : table.string(name);
1478
1617
  }
1479
1618
  if (col) {
1480
1619
  if (field.unique) col.unique();
@@ -1639,6 +1778,16 @@ var SqlDriver = class {
1639
1778
  }
1640
1779
  }
1641
1780
  }
1781
+ const numericFields = this.numericFields[object];
1782
+ if (numericFields && numericFields.length > 0) {
1783
+ for (const field of numericFields) {
1784
+ const v = data[field];
1785
+ if (typeof v === "string" && v.trim() !== "") {
1786
+ const n = Number(v);
1787
+ if (!Number.isNaN(n)) data[field] = n;
1788
+ }
1789
+ }
1790
+ }
1642
1791
  }
1643
1792
  const dateFields = this.dateFields[object];
1644
1793
  if (dateFields && dateFields.size > 0) {