@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 +65 -8
- package/dist/index.d.ts +65 -8
- package/dist/index.js +189 -40
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +189 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
|
|
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
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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.
|
|
409
|
-
|
|
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
|
-
|
|
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({ ...
|
|
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
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
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
|
-
|
|
634
|
+
probe.prefix,
|
|
522
635
|
cfg.tenantField,
|
|
523
636
|
tenantId,
|
|
524
|
-
parentTrx
|
|
637
|
+
parentTrx,
|
|
638
|
+
probe.scope
|
|
525
639
|
);
|
|
526
|
-
row[cfg.name] =
|
|
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
|
|
882
|
-
|
|
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) {
|