@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.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
 
@@ -98,6 +98,7 @@ declare class SqlDriver implements IDataDriver {
98
98
  protected config: Knex.Config;
99
99
  protected jsonFields: Record<string, string[]>;
100
100
  protected booleanFields: Record<string, string[]>;
101
+ protected numericFields: Record<string, string[]>;
101
102
  protected dateFields: Record<string, Set<string>>;
102
103
  protected datetimeFields: Record<string, Set<string>>;
103
104
  protected tablesWithTimestamps: Set<string>;
@@ -118,12 +119,19 @@ declare class SqlDriver implements IDataDriver {
118
119
  protected autoNumberFields: Record<string, Array<{
119
120
  name: string;
120
121
  format: string;
121
- prefix: string;
122
- padWidth: number;
122
+ tokens: AutonumberToken[];
123
123
  tenantField: string | null;
124
124
  }>>;
125
125
  /** Whether the sequences table has been ensured this process. */
126
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;
127
135
  /** In-flight ensure promise; deduplicates concurrent first calls. */
128
136
  protected sequencesTableEnsurePromise: Promise<void> | null;
129
137
  /**
@@ -213,8 +221,34 @@ declare class SqlDriver implements IDataDriver {
213
221
  /**
214
222
  * Ensure the sequence-counter table exists. Idempotent and cheap after
215
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).
216
233
  */
217
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>;
218
252
  /**
219
253
  * Bootstrap helper: scan the data table for the highest numeric suffix
220
254
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -236,12 +270,14 @@ declare class SqlDriver implements IDataDriver {
236
270
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
237
271
  * matching standard sequence semantics.
238
272
  */
239
- 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>;
240
274
  /**
241
- * For each `auto_number` field on the object that the caller did not
242
- * provide a value for, reserve the next sequence value scoped to the
243
- * record's tenant (or globally if the object has no tenant field) and
244
- * 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.
245
281
  */
246
282
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
247
283
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
@@ -429,6 +465,27 @@ declare class SqlDriver implements IDataDriver {
429
465
  * `YYYY-MM-DD` for the same reason.
430
466
  */
431
467
  protected coerceFilterValue(table: string | null, field: string, value: any): any;
468
+ /**
469
+ * Public, dialect-correct temporal filter-value coercion for callers that
470
+ * build SQL *outside* the normal `find()`/`applyFilters()` path — chiefly the
471
+ * analytics native-SQL strategy, which compiles a raw `SELECT … WHERE col >= $N`
472
+ * and binds the value directly, bypassing `coerceFilterValue`.
473
+ *
474
+ * Given a logical object (table) name, a field name and a filter value
475
+ * (typically an ISO date/datetime string from a dashboard relative-date
476
+ * token like `{12_months_ago}`), this returns the value in the column's
477
+ * on-disk storage form:
478
+ * - SQLite `Field.datetime` → epoch milliseconds (INTEGER), so the
479
+ * comparison matches the stored integer rather than failing a
480
+ * TEXT-vs-INTEGER affinity compare.
481
+ * - `Field.date` (any dialect) → `YYYY-MM-DD` text.
482
+ * - Native-timestamp dialects / non-temporal fields → value unchanged.
483
+ *
484
+ * This is a thin, intentionally narrow wrapper over the same `coerceFilterValue`
485
+ * the driver already uses, so there is exactly one source of truth for the
486
+ * storage convention and the analytics path can never drift from CRUD.
487
+ */
488
+ temporalFilterValue(objectName: string, field: string, value: any): any;
432
489
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
433
490
  /**
434
491
  * Apply a `contains` substring match as a parameterized `LIKE '%…%'`, escaping
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
 
@@ -98,6 +98,7 @@ declare class SqlDriver implements IDataDriver {
98
98
  protected config: Knex.Config;
99
99
  protected jsonFields: Record<string, string[]>;
100
100
  protected booleanFields: Record<string, string[]>;
101
+ protected numericFields: Record<string, string[]>;
101
102
  protected dateFields: Record<string, Set<string>>;
102
103
  protected datetimeFields: Record<string, Set<string>>;
103
104
  protected tablesWithTimestamps: Set<string>;
@@ -118,12 +119,19 @@ declare class SqlDriver implements IDataDriver {
118
119
  protected autoNumberFields: Record<string, Array<{
119
120
  name: string;
120
121
  format: string;
121
- prefix: string;
122
- padWidth: number;
122
+ tokens: AutonumberToken[];
123
123
  tenantField: string | null;
124
124
  }>>;
125
125
  /** Whether the sequences table has been ensured this process. */
126
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;
127
135
  /** In-flight ensure promise; deduplicates concurrent first calls. */
128
136
  protected sequencesTableEnsurePromise: Promise<void> | null;
129
137
  /**
@@ -213,8 +221,34 @@ declare class SqlDriver implements IDataDriver {
213
221
  /**
214
222
  * Ensure the sequence-counter table exists. Idempotent and cheap after
215
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).
216
233
  */
217
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>;
218
252
  /**
219
253
  * Bootstrap helper: scan the data table for the highest numeric suffix
220
254
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -236,12 +270,14 @@ declare class SqlDriver implements IDataDriver {
236
270
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
237
271
  * matching standard sequence semantics.
238
272
  */
239
- 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>;
240
274
  /**
241
- * For each `auto_number` field on the object that the caller did not
242
- * provide a value for, reserve the next sequence value scoped to the
243
- * record's tenant (or globally if the object has no tenant field) and
244
- * 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.
245
281
  */
246
282
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
247
283
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
@@ -429,6 +465,27 @@ declare class SqlDriver implements IDataDriver {
429
465
  * `YYYY-MM-DD` for the same reason.
430
466
  */
431
467
  protected coerceFilterValue(table: string | null, field: string, value: any): any;
468
+ /**
469
+ * Public, dialect-correct temporal filter-value coercion for callers that
470
+ * build SQL *outside* the normal `find()`/`applyFilters()` path — chiefly the
471
+ * analytics native-SQL strategy, which compiles a raw `SELECT … WHERE col >= $N`
472
+ * and binds the value directly, bypassing `coerceFilterValue`.
473
+ *
474
+ * Given a logical object (table) name, a field name and a filter value
475
+ * (typically an ISO date/datetime string from a dashboard relative-date
476
+ * token like `{12_months_ago}`), this returns the value in the column's
477
+ * on-disk storage form:
478
+ * - SQLite `Field.datetime` → epoch milliseconds (INTEGER), so the
479
+ * comparison matches the stored integer rather than failing a
480
+ * TEXT-vs-INTEGER affinity compare.
481
+ * - `Field.date` (any dialect) → `YYYY-MM-DD` text.
482
+ * - Native-timestamp dialects / non-temporal fields → value unchanged.
483
+ *
484
+ * This is a thin, intentionally narrow wrapper over the same `coerceFilterValue`
485
+ * the driver already uses, so there is exactly one source of truth for the
486
+ * storage convention and the analytics path can never drift from CRUD.
487
+ */
488
+ temporalFilterValue(objectName: string, field: string, value: any): any;
432
489
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
433
490
  /**
434
491
  * Apply a `contains` substring match as a parameterized `LIKE '%…%'`, escaping
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"));
@@ -48,9 +49,12 @@ var JSON_COLUMN_TYPES = /* @__PURE__ */ new Set([
48
49
  "json",
49
50
  "object",
50
51
  "array",
52
+ "record",
51
53
  "image",
52
54
  "file",
53
55
  "avatar",
56
+ "video",
57
+ "audio",
54
58
  "location",
55
59
  "address",
56
60
  "composite",
@@ -60,6 +64,18 @@ var JSON_COLUMN_TYPES = /* @__PURE__ */ new Set([
60
64
  "repeater",
61
65
  "vector"
62
66
  ]);
67
+ var NUMERIC_SCALAR_TYPES = /* @__PURE__ */ new Set([
68
+ "integer",
69
+ "int",
70
+ "float",
71
+ "number",
72
+ "currency",
73
+ "percent",
74
+ "summary",
75
+ "rating",
76
+ "slider",
77
+ "progress"
78
+ ]);
63
79
  var SqlDriver = class {
64
80
  constructor(config) {
65
81
  // IDataDriver metadata
@@ -67,6 +83,7 @@ var SqlDriver = class {
67
83
  this.version = "1.0.0";
68
84
  this.jsonFields = {};
69
85
  this.booleanFields = {};
86
+ this.numericFields = {};
70
87
  this.dateFields = {};
71
88
  this.datetimeFields = {};
72
89
  this.tablesWithTimestamps = /* @__PURE__ */ new Set();
@@ -87,6 +104,14 @@ var SqlDriver = class {
87
104
  this.autoNumberFields = {};
88
105
  /** Whether the sequences table has been ensured this process. */
89
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;
90
115
  /** In-flight ensure promise; deduplicates concurrent first calls. */
91
116
  this.sequencesTableEnsurePromise = null;
92
117
  /**
@@ -394,6 +419,15 @@ var SqlDriver = class {
394
419
  /**
395
420
  * Ensure the sequence-counter table exists. Idempotent and cheap after
396
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).
397
431
  */
398
432
  async ensureSequencesTable() {
399
433
  if (this.sequencesTableReady) return;
@@ -405,18 +439,15 @@ var SqlDriver = class {
405
439
  const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
406
440
  if (!exists) {
407
441
  try {
408
- await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
409
- t.string("object").notNullable();
410
- t.string("tenant_id").notNullable();
411
- t.string("field").notNullable();
412
- t.bigInteger("last_value").notNullable().defaultTo(0);
413
- t.timestamp("updated_at").defaultTo(this.knex.fn.now());
414
- t.primary(["object", "tenant_id", "field"]);
415
- });
442
+ await this.createSequencesTable(SEQUENCES_TABLE);
443
+ this.sequencesHasKeyHash = true;
416
444
  } catch (err) {
417
445
  const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
418
446
  if (stillMissing) throw err;
447
+ await this.ensureSequencesKeyHashShape();
419
448
  }
449
+ } else {
450
+ await this.ensureSequencesKeyHashShape();
420
451
  }
421
452
  this.sequencesTableReady = true;
422
453
  })();
@@ -426,6 +457,71 @@ var SqlDriver = class {
426
457
  this.sequencesTableEnsurePromise = null;
427
458
  }
428
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
+ }
429
525
  /**
430
526
  * Bootstrap helper: scan the data table for the highest numeric suffix
431
527
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -463,10 +559,16 @@ var SqlDriver = class {
463
559
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
464
560
  * matching standard sequence semantics.
465
561
  */
466
- async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
562
+ async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx, scope = "") {
467
563
  await this.ensureSequencesTable();
468
564
  const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
469
- 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 };
470
572
  const runner = parentTrx ?? this.knex;
471
573
  return runner.transaction(async (trx) => {
472
574
  let existing;
@@ -486,7 +588,7 @@ var SqlDriver = class {
486
588
  );
487
589
  const initial = seedMax + 1;
488
590
  try {
489
- await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
591
+ await trx(SEQUENCES_TABLE).insert({ ...insertRow, last_value: initial });
490
592
  return initial;
491
593
  } catch (err) {
492
594
  existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
@@ -499,31 +601,43 @@ var SqlDriver = class {
499
601
  });
500
602
  }
501
603
  /**
502
- * For each `auto_number` field on the object that the caller did not
503
- * provide a value for, reserve the next sequence value scoped to the
504
- * record's tenant (or globally if the object has no tenant field) and
505
- * 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.
506
610
  */
507
611
  async fillAutoNumberFields(object, row, options) {
508
612
  const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
509
613
  const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
510
614
  if (!cfgs || cfgs.length === 0) return;
511
615
  const parentTrx = options?.transaction;
616
+ const timezone = options?.timezone;
617
+ const now = /* @__PURE__ */ new Date();
512
618
  for (const cfg of cfgs) {
513
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
+ }
514
626
  const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
515
627
  const optTenant = options?.tenantId;
516
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 });
517
630
  const next = await this.getNextSequenceValue(
518
631
  object,
519
632
  tableName,
520
633
  cfg.name,
521
- cfg.prefix,
634
+ probe.prefix,
522
635
  cfg.tenantField,
523
636
  tenantId,
524
- parentTrx
637
+ parentTrx,
638
+ probe.scope
525
639
  );
526
- 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;
527
641
  }
528
642
  }
529
643
  async update(object, id, data, options) {
@@ -847,6 +961,7 @@ var SqlDriver = class {
847
961
  const tableName = import_system.StorageNameMapping.resolveTableName(obj);
848
962
  const jsonCols = [];
849
963
  const booleanCols = [];
964
+ const numericCols = [];
850
965
  const autoNumberCols = [];
851
966
  const tenancyDecl = obj?.tenancy;
852
967
  let tenantField = null;
@@ -866,9 +981,12 @@ var SqlDriver = class {
866
981
  if (this.isJsonField(type, field)) {
867
982
  jsonCols.push(name);
868
983
  }
869
- if (type === "boolean") {
984
+ if (type === "boolean" || type === "toggle") {
870
985
  booleanCols.push(name);
871
986
  }
987
+ if (NUMERIC_SCALAR_TYPES.has(type) && !field.multiple) {
988
+ numericCols.push(name);
989
+ }
872
990
  if (type === "date") {
873
991
  ((_a = this.dateFields)[tableName] ?? (_a[tableName] = /* @__PURE__ */ new Set())).add(name);
874
992
  }
@@ -878,15 +996,14 @@ var SqlDriver = class {
878
996
  if (type === "auto_number" || type === "autonumber") {
879
997
  const rawFmt = typeof field.autonumberFormat === "string" && field.autonumberFormat ? field.autonumberFormat : typeof field.format === "string" && field.format ? field.format : "";
880
998
  const fmt = rawFmt || "{0000}";
881
- const m = fmt.match(/\{(0+)\}/);
882
- const padWidth = m ? m[1].length : 4;
883
- const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
884
- autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
999
+ const tokens = (0, import_data.parseAutonumberFormat)(fmt);
1000
+ autoNumberCols.push({ name, format: fmt, tokens, tenantField });
885
1001
  }
886
1002
  }
887
1003
  }
888
1004
  this.jsonFields[tableName] = jsonCols;
889
1005
  this.booleanFields[tableName] = booleanCols;
1006
+ this.numericFields[tableName] = numericCols;
890
1007
  this.autoNumberFields[tableName] = autoNumberCols;
891
1008
  this.tenantFieldByTable[tableName] = tenantField;
892
1009
  let exists = await this.knex.schema.hasTable(tableName);
@@ -1238,11 +1355,35 @@ var SqlDriver = class {
1238
1355
  return null;
1239
1356
  };
1240
1357
  if (isDatetime) {
1358
+ if (!this.isSqlite) return value;
1241
1359
  const ms = toMs(value);
1242
1360
  return ms == null ? value : ms;
1243
1361
  }
1244
1362
  return this.toDateOnly(value);
1245
1363
  }
1364
+ /**
1365
+ * Public, dialect-correct temporal filter-value coercion for callers that
1366
+ * build SQL *outside* the normal `find()`/`applyFilters()` path — chiefly the
1367
+ * analytics native-SQL strategy, which compiles a raw `SELECT … WHERE col >= $N`
1368
+ * and binds the value directly, bypassing `coerceFilterValue`.
1369
+ *
1370
+ * Given a logical object (table) name, a field name and a filter value
1371
+ * (typically an ISO date/datetime string from a dashboard relative-date
1372
+ * token like `{12_months_ago}`), this returns the value in the column's
1373
+ * on-disk storage form:
1374
+ * - SQLite `Field.datetime` → epoch milliseconds (INTEGER), so the
1375
+ * comparison matches the stored integer rather than failing a
1376
+ * TEXT-vs-INTEGER affinity compare.
1377
+ * - `Field.date` (any dialect) → `YYYY-MM-DD` text.
1378
+ * - Native-timestamp dialects / non-temporal fields → value unchanged.
1379
+ *
1380
+ * This is a thin, intentionally narrow wrapper over the same `coerceFilterValue`
1381
+ * the driver already uses, so there is exactly one source of truth for the
1382
+ * storage convention and the analytics path can never drift from CRUD.
1383
+ */
1384
+ temporalFilterValue(objectName, field, value) {
1385
+ return this.coerceFilterValue(objectName, field, value);
1386
+ }
1246
1387
  applyFilters(builder, filters) {
1247
1388
  if (!filters) return;
1248
1389
  const table = this.tableNameForBuilder(builder);
@@ -1464,9 +1605,23 @@ var SqlDriver = class {
1464
1605
  case "number":
1465
1606
  case "currency":
1466
1607
  case "percent":
1608
+ // `rating`/`slider`/`progress` are authored as numeric scalars (a star
1609
+ // count, a slider position, a percent-of-completion). Without an explicit
1610
+ // case they fell to `default → table.string`, giving the column TEXT
1611
+ // affinity so SQLite coerced the written number to a string ('4' not 4) —
1612
+ // a silent type-fidelity leak the value-loss tests didn't catch. REAL
1613
+ // affinity round-trips them as JS numbers (#field-zoo).
1614
+ case "rating":
1615
+ case "slider":
1616
+ case "progress":
1467
1617
  col = table.float(name);
1468
1618
  break;
1619
+ // `toggle` is a boolean rendered as a switch. Same leak as above (TEXT
1620
+ // affinity stored '1'); a boolean column gives NUMERIC affinity and the
1621
+ // `booleanFields` read-coercion below converts the stored 1/0 back to a
1622
+ // real JS boolean.
1469
1623
  case "boolean":
1624
+ case "toggle":
1470
1625
  col = table.boolean(name);
1471
1626
  break;
1472
1627
  case "date":
@@ -1478,22 +1633,6 @@ var SqlDriver = class {
1478
1633
  case "time":
1479
1634
  col = table.time(name);
1480
1635
  break;
1481
- case "json":
1482
- case "object":
1483
- case "array":
1484
- case "image":
1485
- case "file":
1486
- case "avatar":
1487
- case "location":
1488
- case "address":
1489
- case "composite":
1490
- case "multiselect":
1491
- case "checkboxes":
1492
- case "tags":
1493
- case "repeater":
1494
- case "vector":
1495
- col = table.json(name);
1496
- break;
1497
1636
  case "lookup":
1498
1637
  col = table.string(name);
1499
1638
  if (field.reference_to) {
@@ -1511,7 +1650,7 @@ var SqlDriver = class {
1511
1650
  return;
1512
1651
  // Virtual — no column
1513
1652
  default:
1514
- col = table.string(name);
1653
+ col = JSON_COLUMN_TYPES.has(type) ? table.json(name) : table.string(name);
1515
1654
  }
1516
1655
  if (col) {
1517
1656
  if (field.unique) col.unique();
@@ -1676,6 +1815,16 @@ var SqlDriver = class {
1676
1815
  }
1677
1816
  }
1678
1817
  }
1818
+ const numericFields = this.numericFields[object];
1819
+ if (numericFields && numericFields.length > 0) {
1820
+ for (const field of numericFields) {
1821
+ const v = data[field];
1822
+ if (typeof v === "string" && v.trim() !== "") {
1823
+ const n = Number(v);
1824
+ if (!Number.isNaN(n)) data[field] = n;
1825
+ }
1826
+ }
1827
+ }
1679
1828
  }
1680
1829
  const dateFields = this.dateFields[object];
1681
1830
  if (dateFields && dateFields.size > 0) {