@objectstack/driver-sql 4.0.5 → 4.1.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/README.md +57 -0
- package/dist/index.d.mts +175 -1
- package/dist/index.d.ts +175 -1
- package/dist/index.js +486 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +486 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
package/dist/index.mjs
CHANGED
|
@@ -3,12 +3,64 @@ import { StorageNameMapping } from "@objectstack/spec/system";
|
|
|
3
3
|
import knex from "knex";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
5
|
var DEFAULT_ID_LENGTH = 16;
|
|
6
|
+
var SEQUENCES_TABLE = "_objectstack_sequences";
|
|
7
|
+
var GLOBAL_TENANT = "__global__";
|
|
6
8
|
var SqlDriver = class {
|
|
7
9
|
constructor(config) {
|
|
8
10
|
// IDataDriver metadata
|
|
9
11
|
this.name = "com.objectstack.driver.sql";
|
|
10
12
|
this.version = "1.0.0";
|
|
11
|
-
this.
|
|
13
|
+
this.jsonFields = {};
|
|
14
|
+
this.booleanFields = {};
|
|
15
|
+
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
16
|
+
/**
|
|
17
|
+
* Autonumber field configs per table, captured during initObjects.
|
|
18
|
+
*
|
|
19
|
+
* Each entry records:
|
|
20
|
+
* - `prefix` + `padWidth`: how to render the next value (`CTR-0007`)
|
|
21
|
+
* - `tenantField`: the column to scope the sequence by (defaults to
|
|
22
|
+
* `organization_id` if the object has that field, otherwise null →
|
|
23
|
+
* sequence is shared globally for that field)
|
|
24
|
+
*
|
|
25
|
+
* Numbering is backed by the `_objectstack_sequences` row keyed by
|
|
26
|
+
* `(object, tenant_id, field)`, not by scanning the data table on each
|
|
27
|
+
* insert. The sequence row is bootstrapped from the existing MAX on
|
|
28
|
+
* first use so legacy data is respected.
|
|
29
|
+
*/
|
|
30
|
+
this.autoNumberFields = {};
|
|
31
|
+
/** Whether the sequences table has been ensured this process. */
|
|
32
|
+
this.sequencesTableReady = false;
|
|
33
|
+
/** In-flight ensure promise; deduplicates concurrent first calls. */
|
|
34
|
+
this.sequencesTableEnsurePromise = null;
|
|
35
|
+
/**
|
|
36
|
+
* Per-table tenant-isolation column. Populated during `initObjects` by
|
|
37
|
+
* detecting an `organization_id` field. When set and the caller passes
|
|
38
|
+
* `DriverOptions.tenantId`, the driver automatically:
|
|
39
|
+
*
|
|
40
|
+
* - scopes reads/updates/deletes/aggregates to that tenant
|
|
41
|
+
* - injects `organization_id` on inserts that omit it
|
|
42
|
+
*
|
|
43
|
+
* If `tenantId` is absent (admin / seed / system path) no scope is
|
|
44
|
+
* applied — preserves backward compatibility for tools that legitimately
|
|
45
|
+
* need cross-tenant access. Tenant enforcement is therefore opt-in by
|
|
46
|
+
* the caller, not by the driver.
|
|
47
|
+
*/
|
|
48
|
+
this.tenantFieldByTable = {};
|
|
49
|
+
/** Throttle table for missing-tenantId warnings ({object}:{op}). */
|
|
50
|
+
this.tenantAuditWarned = /* @__PURE__ */ new Set();
|
|
51
|
+
/**
|
|
52
|
+
* Optional logger sink for security-audit warnings. Tests inject a spy;
|
|
53
|
+
* production callers wire in their preferred logger. Defaults to
|
|
54
|
+
* `console.warn` so warnings surface even without setup.
|
|
55
|
+
*/
|
|
56
|
+
this.logger = {
|
|
57
|
+
warn: (msg, meta) => console.warn(msg, meta ?? "")
|
|
58
|
+
};
|
|
59
|
+
this.config = config;
|
|
60
|
+
this.knex = knex(config);
|
|
61
|
+
}
|
|
62
|
+
get supports() {
|
|
63
|
+
return {
|
|
12
64
|
// Basic CRUD Operations
|
|
13
65
|
create: true,
|
|
14
66
|
read: true,
|
|
@@ -24,6 +76,12 @@ var SqlDriver = class {
|
|
|
24
76
|
// Query Operations
|
|
25
77
|
queryFilters: true,
|
|
26
78
|
queryAggregations: true,
|
|
79
|
+
/**
|
|
80
|
+
* Per-granularity native date bucket support. Granularities marked
|
|
81
|
+
* `false` (or absent) fall back to in-memory `bucketDateValue()` via
|
|
82
|
+
* `engine.findData` — see `buildDateBucketExpr()` for the SQL emitted.
|
|
83
|
+
*/
|
|
84
|
+
queryDateGranularity: this.dateGranularityCapabilities,
|
|
27
85
|
querySorting: true,
|
|
28
86
|
queryPagination: true,
|
|
29
87
|
queryWindowFunctions: true,
|
|
@@ -48,11 +106,6 @@ var SqlDriver = class {
|
|
|
48
106
|
preparedStatements: true,
|
|
49
107
|
queryCache: false
|
|
50
108
|
};
|
|
51
|
-
this.jsonFields = {};
|
|
52
|
-
this.booleanFields = {};
|
|
53
|
-
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
54
|
-
this.config = config;
|
|
55
|
-
this.knex = knex(config);
|
|
56
109
|
}
|
|
57
110
|
/** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
|
|
58
111
|
get isSqlite() {
|
|
@@ -69,6 +122,86 @@ var SqlDriver = class {
|
|
|
69
122
|
const c = this.config.client;
|
|
70
123
|
return c === "mysql" || c === "mysql2";
|
|
71
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Per-granularity native SQL bucket support, computed from dialect.
|
|
127
|
+
*
|
|
128
|
+
* Must match `bucketDateValue()` in @objectstack/objectql exactly:
|
|
129
|
+
* year → 'YYYY'
|
|
130
|
+
* month → 'YYYY-MM'
|
|
131
|
+
* day → 'YYYY-MM-DD'
|
|
132
|
+
* quarter → 'YYYY-Q[1-4]'
|
|
133
|
+
* week → 'YYYY-W[01-53]' (ISO-8601)
|
|
134
|
+
*
|
|
135
|
+
* Granularities not listed (or set to false) fall back to in-memory bucketing
|
|
136
|
+
* via engine.findData → applyInMemoryAggregation.
|
|
137
|
+
*/
|
|
138
|
+
get dateGranularityCapabilities() {
|
|
139
|
+
if (this.isPostgres) {
|
|
140
|
+
return { day: true, month: true, quarter: true, year: true, week: true };
|
|
141
|
+
}
|
|
142
|
+
if (this.isMysql) {
|
|
143
|
+
return { day: true, month: true, quarter: true, year: true, week: true };
|
|
144
|
+
}
|
|
145
|
+
if (this.isSqlite) {
|
|
146
|
+
return { day: true, month: true, quarter: true, year: true, week: false };
|
|
147
|
+
}
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build SQL fragment + bindings for a date bucket expression.
|
|
152
|
+
* Returns `null` when the current dialect does not support the requested
|
|
153
|
+
* granularity — callers must fall back to in-memory bucketing.
|
|
154
|
+
*
|
|
155
|
+
* Exposed as `{sql, bindings}` (not `Knex.Raw`) so callers can both
|
|
156
|
+
* `groupByRaw()` and embed the same expression inside a `select() as alias`
|
|
157
|
+
* with correctly forwarded identifier bindings.
|
|
158
|
+
*/
|
|
159
|
+
buildDateBucketExpr(field, granularity) {
|
|
160
|
+
if (!this.dateGranularityCapabilities[granularity]) return null;
|
|
161
|
+
if (this.isPostgres) {
|
|
162
|
+
switch (granularity) {
|
|
163
|
+
case "year":
|
|
164
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY')`, bindings: [field] };
|
|
165
|
+
case "month":
|
|
166
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')`, bindings: [field] };
|
|
167
|
+
case "day":
|
|
168
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM-DD')`, bindings: [field] };
|
|
169
|
+
case "quarter":
|
|
170
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY"-Q"Q')`, bindings: [field] };
|
|
171
|
+
case "week":
|
|
172
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'IYYY"-W"IW')`, bindings: [field] };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (this.isMysql) {
|
|
176
|
+
switch (granularity) {
|
|
177
|
+
case "year":
|
|
178
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y')`, bindings: [field] };
|
|
179
|
+
case "month":
|
|
180
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y-%m')`, bindings: [field] };
|
|
181
|
+
case "day":
|
|
182
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y-%m-%d')`, bindings: [field] };
|
|
183
|
+
case "quarter":
|
|
184
|
+
return { sql: `concat(date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y'), '-Q', quarter(convert_tz(??, @@session.time_zone, '+00:00')))`, bindings: [field, field] };
|
|
185
|
+
case "week":
|
|
186
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%x-W%v')`, bindings: [field] };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (this.isSqlite) {
|
|
190
|
+
switch (granularity) {
|
|
191
|
+
case "year":
|
|
192
|
+
return { sql: `strftime('%Y', ??)`, bindings: [field] };
|
|
193
|
+
case "month":
|
|
194
|
+
return { sql: `strftime('%Y-%m', ??)`, bindings: [field] };
|
|
195
|
+
case "day":
|
|
196
|
+
return { sql: `strftime('%Y-%m-%d', ??)`, bindings: [field] };
|
|
197
|
+
case "quarter":
|
|
198
|
+
return { sql: `(strftime('%Y', ??) || '-Q' || ((cast(strftime('%m', ??) as integer) - 1) / 3 + 1))`, bindings: [field, field] };
|
|
199
|
+
case "week":
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
72
205
|
// ===================================
|
|
73
206
|
// Lifecycle
|
|
74
207
|
// ===================================
|
|
@@ -91,6 +224,7 @@ var SqlDriver = class {
|
|
|
91
224
|
// ===================================
|
|
92
225
|
async find(object, query, options) {
|
|
93
226
|
const builder = this.getBuilder(object, options);
|
|
227
|
+
this.applyTenantScope(builder, object, options);
|
|
94
228
|
if (query.fields) {
|
|
95
229
|
builder.select(query.fields.map((f) => this.mapSortField(f)));
|
|
96
230
|
} else {
|
|
@@ -129,7 +263,9 @@ var SqlDriver = class {
|
|
|
129
263
|
}
|
|
130
264
|
async findOne(object, query, options) {
|
|
131
265
|
if (typeof query === "string" || typeof query === "number") {
|
|
132
|
-
const
|
|
266
|
+
const builder = this.getBuilder(object, options).where("id", query);
|
|
267
|
+
this.applyTenantScope(builder, object, options);
|
|
268
|
+
const res = await builder.first();
|
|
133
269
|
return this.formatOutput(object, res) || null;
|
|
134
270
|
}
|
|
135
271
|
if (query && typeof query === "object") {
|
|
@@ -157,13 +293,153 @@ var SqlDriver = class {
|
|
|
157
293
|
} else if (toInsert.id === void 0) {
|
|
158
294
|
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
|
|
159
295
|
}
|
|
296
|
+
this.auditMissingTenant(object, "create", options);
|
|
297
|
+
this.injectTenantOnInsert(object, toInsert, options);
|
|
298
|
+
await this.fillAutoNumberFields(object, toInsert, options);
|
|
160
299
|
const builder = this.getBuilder(object, options);
|
|
161
300
|
const formatted = this.formatInput(object, toInsert);
|
|
162
301
|
const result = await builder.insert(formatted).returning("*");
|
|
163
302
|
return this.formatOutput(object, result[0]);
|
|
164
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Ensure the sequence-counter table exists. Idempotent and cheap after
|
|
306
|
+
* the first call (cached via `sequencesTableReady`).
|
|
307
|
+
*/
|
|
308
|
+
async ensureSequencesTable() {
|
|
309
|
+
if (this.sequencesTableReady) return;
|
|
310
|
+
if (this.sequencesTableEnsurePromise) {
|
|
311
|
+
await this.sequencesTableEnsurePromise;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.sequencesTableEnsurePromise = (async () => {
|
|
315
|
+
const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
|
|
316
|
+
if (!exists) {
|
|
317
|
+
try {
|
|
318
|
+
await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
|
|
319
|
+
t.string("object").notNullable();
|
|
320
|
+
t.string("tenant_id").notNullable();
|
|
321
|
+
t.string("field").notNullable();
|
|
322
|
+
t.bigInteger("last_value").notNullable().defaultTo(0);
|
|
323
|
+
t.timestamp("updated_at").defaultTo(this.knex.fn.now());
|
|
324
|
+
t.primary(["object", "tenant_id", "field"]);
|
|
325
|
+
});
|
|
326
|
+
} catch (err) {
|
|
327
|
+
const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
|
|
328
|
+
if (stillMissing) throw err;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
this.sequencesTableReady = true;
|
|
332
|
+
})();
|
|
333
|
+
try {
|
|
334
|
+
await this.sequencesTableEnsurePromise;
|
|
335
|
+
} finally {
|
|
336
|
+
this.sequencesTableEnsurePromise = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Bootstrap helper: scan the data table for the highest numeric suffix
|
|
341
|
+
* matching `prefix` (optionally scoped to a tenant). Used the first time
|
|
342
|
+
* a sequence row is created so legacy/seeded data continues monotonically.
|
|
343
|
+
*/
|
|
344
|
+
async scanMaxNumericTail(queryRunner, tableName, field, prefix, tenantField, tenantId) {
|
|
345
|
+
const escapedPrefix = prefix.replace(/([\\%_])/g, "\\$1");
|
|
346
|
+
let builder = queryRunner(tableName).select(field).where(field, "like", `${escapedPrefix}%`).whereNotNull(field);
|
|
347
|
+
if (tenantField && tenantId !== null) {
|
|
348
|
+
builder = builder.where(tenantField, tenantId);
|
|
349
|
+
}
|
|
350
|
+
const rows = await builder;
|
|
351
|
+
let maxN = 0;
|
|
352
|
+
for (const r of rows) {
|
|
353
|
+
const v = r[field];
|
|
354
|
+
if (typeof v !== "string") continue;
|
|
355
|
+
const tail = v.slice(prefix.length);
|
|
356
|
+
const n = parseInt(tail.replace(/[^0-9]/g, ""), 10);
|
|
357
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
358
|
+
}
|
|
359
|
+
return maxN;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Atomically reserve and return the next sequence value for
|
|
363
|
+
* `(object, tenantId, field)`. Bootstraps from the data-table MAX on
|
|
364
|
+
* first call so existing seeded records continue monotonically.
|
|
365
|
+
*
|
|
366
|
+
* Concurrency:
|
|
367
|
+
* - SQLite: a write transaction (`BEGIN IMMEDIATE` via knex) serializes
|
|
368
|
+
* all writers; safe in-process. Cross-process SQLite is out of scope.
|
|
369
|
+
* - Postgres/MySQL: `SELECT … FOR UPDATE` row lock ensures only one
|
|
370
|
+
* transaction reads-modifies-writes at a time. A PK-violation race on
|
|
371
|
+
* first insert is retried as an UPDATE.
|
|
372
|
+
*
|
|
373
|
+
* Gaps are tolerated by design — a rolled-back insert "burns" a number,
|
|
374
|
+
* matching standard sequence semantics.
|
|
375
|
+
*/
|
|
376
|
+
async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
|
|
377
|
+
await this.ensureSequencesTable();
|
|
378
|
+
const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
|
|
379
|
+
const key = { object: tableName, tenant_id: resolvedTenantId, field };
|
|
380
|
+
const runner = parentTrx ?? this.knex;
|
|
381
|
+
return runner.transaction(async (trx) => {
|
|
382
|
+
let existing;
|
|
383
|
+
try {
|
|
384
|
+
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
|
|
385
|
+
} catch {
|
|
386
|
+
existing = await trx(SEQUENCES_TABLE).where(key).first();
|
|
387
|
+
}
|
|
388
|
+
if (!existing) {
|
|
389
|
+
const seedMax = await this.scanMaxNumericTail(
|
|
390
|
+
trx,
|
|
391
|
+
tableName,
|
|
392
|
+
field,
|
|
393
|
+
prefix,
|
|
394
|
+
tenantField,
|
|
395
|
+
resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId
|
|
396
|
+
);
|
|
397
|
+
const initial = seedMax + 1;
|
|
398
|
+
try {
|
|
399
|
+
await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
|
|
400
|
+
return initial;
|
|
401
|
+
} catch (err) {
|
|
402
|
+
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
|
|
403
|
+
if (!existing) throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const next = Number(existing.last_value) + 1;
|
|
407
|
+
await trx(SEQUENCES_TABLE).where(key).update({ last_value: next, updated_at: this.knex.fn.now() });
|
|
408
|
+
return next;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* For each `auto_number` field on the object that the caller did not
|
|
413
|
+
* provide a value for, reserve the next sequence value scoped to the
|
|
414
|
+
* record's tenant (or globally if the object has no tenant field) and
|
|
415
|
+
* render `prefix + zero-padded(value)`.
|
|
416
|
+
*/
|
|
417
|
+
async fillAutoNumberFields(object, row, options) {
|
|
418
|
+
const tableName = StorageNameMapping.resolveTableName({ name: object });
|
|
419
|
+
const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
|
|
420
|
+
if (!cfgs || cfgs.length === 0) return;
|
|
421
|
+
const parentTrx = options?.transaction;
|
|
422
|
+
for (const cfg of cfgs) {
|
|
423
|
+
if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
|
|
424
|
+
const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
|
|
425
|
+
const optTenant = options?.tenantId;
|
|
426
|
+
const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
|
|
427
|
+
const next = await this.getNextSequenceValue(
|
|
428
|
+
object,
|
|
429
|
+
tableName,
|
|
430
|
+
cfg.name,
|
|
431
|
+
cfg.prefix,
|
|
432
|
+
cfg.tenantField,
|
|
433
|
+
tenantId,
|
|
434
|
+
parentTrx
|
|
435
|
+
);
|
|
436
|
+
row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
165
439
|
async update(object, id, data, options) {
|
|
166
|
-
|
|
440
|
+
this.auditMissingTenant(object, "update", options);
|
|
441
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
442
|
+
this.applyTenantScope(builder, object, options);
|
|
167
443
|
const formatted = this.formatInput(object, data);
|
|
168
444
|
if (this.tablesWithTimestamps.has(object)) {
|
|
169
445
|
if (this.isSqlite) {
|
|
@@ -173,8 +449,10 @@ var SqlDriver = class {
|
|
|
173
449
|
formatted.updated_at = this.knex.fn.now();
|
|
174
450
|
}
|
|
175
451
|
}
|
|
176
|
-
await builder.
|
|
177
|
-
const
|
|
452
|
+
await builder.update(formatted);
|
|
453
|
+
const readback = this.getBuilder(object, options).where("id", id);
|
|
454
|
+
this.applyTenantScope(readback, object, options);
|
|
455
|
+
const updated = await readback.first();
|
|
178
456
|
return this.formatOutput(object, updated) || null;
|
|
179
457
|
}
|
|
180
458
|
async upsert(object, data, conflictKeys, options) {
|
|
@@ -185,22 +463,33 @@ var SqlDriver = class {
|
|
|
185
463
|
} else if (toUpsert.id === void 0) {
|
|
186
464
|
toUpsert.id = nanoid(DEFAULT_ID_LENGTH);
|
|
187
465
|
}
|
|
466
|
+
this.auditMissingTenant(object, "upsert", options);
|
|
467
|
+
this.injectTenantOnInsert(object, toUpsert, options);
|
|
468
|
+
await this.fillAutoNumberFields(object, toUpsert, options);
|
|
188
469
|
const formatted = this.formatInput(object, toUpsert);
|
|
189
470
|
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
|
|
190
471
|
const builder = this.getBuilder(object, options);
|
|
191
472
|
await builder.insert(formatted).onConflict(mergeKeys).merge();
|
|
192
|
-
const
|
|
473
|
+
const readback = this.getBuilder(object, options).where("id", toUpsert.id);
|
|
474
|
+
this.applyTenantScope(readback, object, options);
|
|
475
|
+
const result = await readback.first();
|
|
193
476
|
return this.formatOutput(object, result) || toUpsert;
|
|
194
477
|
}
|
|
195
478
|
async delete(object, id, options) {
|
|
196
|
-
|
|
197
|
-
const
|
|
479
|
+
this.auditMissingTenant(object, "delete", options);
|
|
480
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
481
|
+
this.applyTenantScope(builder, object, options);
|
|
482
|
+
const count = await builder.delete();
|
|
198
483
|
return count > 0;
|
|
199
484
|
}
|
|
200
485
|
// ===================================
|
|
201
486
|
// Bulk & Batch Operations
|
|
202
487
|
// ===================================
|
|
203
488
|
async bulkCreate(object, data, options) {
|
|
489
|
+
this.auditMissingTenant(object, "bulkCreate", options);
|
|
490
|
+
for (const row of data) {
|
|
491
|
+
if (row && typeof row === "object") this.injectTenantOnInsert(object, row, options);
|
|
492
|
+
}
|
|
204
493
|
const builder = this.getBuilder(object, options);
|
|
205
494
|
return await builder.insert(data).returning("*");
|
|
206
495
|
}
|
|
@@ -218,23 +507,30 @@ var SqlDriver = class {
|
|
|
218
507
|
return results;
|
|
219
508
|
}
|
|
220
509
|
async bulkDelete(object, ids, options) {
|
|
221
|
-
|
|
222
|
-
|
|
510
|
+
this.auditMissingTenant(object, "bulkDelete", options);
|
|
511
|
+
const builder = this.getBuilder(object, options).whereIn("id", ids);
|
|
512
|
+
this.applyTenantScope(builder, object, options);
|
|
513
|
+
await builder.delete();
|
|
223
514
|
}
|
|
224
515
|
async updateMany(object, query, data, options) {
|
|
516
|
+
this.auditMissingTenant(object, "updateMany", options);
|
|
225
517
|
const builder = this.getBuilder(object, options);
|
|
518
|
+
this.applyTenantScope(builder, object, options);
|
|
226
519
|
if (query.where) this.applyFilters(builder, query.where);
|
|
227
520
|
const count = await builder.update(data);
|
|
228
521
|
return count || 0;
|
|
229
522
|
}
|
|
230
523
|
async deleteMany(object, query, options) {
|
|
524
|
+
this.auditMissingTenant(object, "deleteMany", options);
|
|
231
525
|
const builder = this.getBuilder(object, options);
|
|
526
|
+
this.applyTenantScope(builder, object, options);
|
|
232
527
|
if (query.where) this.applyFilters(builder, query.where);
|
|
233
528
|
const count = await builder.delete();
|
|
234
529
|
return count || 0;
|
|
235
530
|
}
|
|
236
531
|
async count(object, query, options) {
|
|
237
532
|
const builder = this.getBuilder(object, options);
|
|
533
|
+
this.applyTenantScope(builder, object, options);
|
|
238
534
|
if (query?.where) {
|
|
239
535
|
this.applyFilters(builder, query.where);
|
|
240
536
|
}
|
|
@@ -248,6 +544,22 @@ var SqlDriver = class {
|
|
|
248
544
|
// ===================================
|
|
249
545
|
// Raw Execution
|
|
250
546
|
// ===================================
|
|
547
|
+
/**
|
|
548
|
+
* Run a raw SQL string or knex builder through the underlying knex
|
|
549
|
+
* connection.
|
|
550
|
+
*
|
|
551
|
+
* ⚠️ **Tenant isolation bypass.** Unlike `find`/`update`/`delete` etc.,
|
|
552
|
+
* raw `execute()` does NOT inject the `organization_id` predicate. The
|
|
553
|
+
* caller is responsible for either:
|
|
554
|
+
* - inlining the tenant filter into the SQL (`WHERE organization_id = ?`),
|
|
555
|
+
* - or restricting `execute()` to genuinely global queries
|
|
556
|
+
* (schema introspection, sys_* tables that opt out of tenancy).
|
|
557
|
+
*
|
|
558
|
+
* Prefer the typed CRUD APIs whenever the operation can be expressed
|
|
559
|
+
* through them — they handle tenancy, soft-delete, and audit warnings
|
|
560
|
+
* automatically. See `README.md > Tenant Isolation` for the full bypass
|
|
561
|
+
* matrix.
|
|
562
|
+
*/
|
|
251
563
|
async execute(command, params, options) {
|
|
252
564
|
if (typeof command !== "string") {
|
|
253
565
|
return command;
|
|
@@ -282,13 +594,30 @@ var SqlDriver = class {
|
|
|
282
594
|
// ===================================
|
|
283
595
|
async aggregate(object, query, options) {
|
|
284
596
|
const builder = this.getBuilder(object, options);
|
|
597
|
+
this.applyTenantScope(builder, object, options);
|
|
285
598
|
if (query.where) {
|
|
286
599
|
this.applyFilters(builder, query.where);
|
|
287
600
|
}
|
|
288
601
|
if (query.groupBy) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
602
|
+
for (const g of query.groupBy) {
|
|
603
|
+
if (typeof g === "string") {
|
|
604
|
+
builder.groupBy(g);
|
|
605
|
+
builder.select(g);
|
|
606
|
+
} else if (g && typeof g === "object" && g.field) {
|
|
607
|
+
if (g.dateGranularity) {
|
|
608
|
+
const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity);
|
|
609
|
+
if (!bucket) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
`SqlDriver: dateGranularity '${g.dateGranularity}' not supported on dialect '${this.config.client}'. Engine must fall back to in-memory bucketing.`
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
builder.groupByRaw(bucket.sql, bucket.bindings);
|
|
615
|
+
builder.select(this.knex.raw(`${bucket.sql} as ??`, [...bucket.bindings, g.field]));
|
|
616
|
+
} else {
|
|
617
|
+
builder.groupBy(g.field);
|
|
618
|
+
builder.select(g.field);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
292
621
|
}
|
|
293
622
|
}
|
|
294
623
|
const aggregates = query.aggregations || query.aggregate;
|
|
@@ -296,10 +625,19 @@ var SqlDriver = class {
|
|
|
296
625
|
for (const agg of aggregates) {
|
|
297
626
|
const funcName = agg.function || agg.func;
|
|
298
627
|
const rawFunc = this.mapAggregateFunc(funcName);
|
|
628
|
+
const fieldExpr = agg.field ?? "*";
|
|
299
629
|
if (agg.alias) {
|
|
300
|
-
|
|
630
|
+
if (fieldExpr === "*") {
|
|
631
|
+
builder.select(this.knex.raw(`${rawFunc}(*) as ??`, [agg.alias]));
|
|
632
|
+
} else {
|
|
633
|
+
builder.select(this.knex.raw(`${rawFunc}(??) as ??`, [fieldExpr, agg.alias]));
|
|
634
|
+
}
|
|
301
635
|
} else {
|
|
302
|
-
|
|
636
|
+
if (fieldExpr === "*") {
|
|
637
|
+
builder.select(this.knex.raw(`${rawFunc}(*)`));
|
|
638
|
+
} else {
|
|
639
|
+
builder.select(this.knex.raw(`${rawFunc}(??)`, [fieldExpr]));
|
|
640
|
+
}
|
|
303
641
|
}
|
|
304
642
|
}
|
|
305
643
|
}
|
|
@@ -413,6 +751,19 @@ var SqlDriver = class {
|
|
|
413
751
|
const tableName = StorageNameMapping.resolveTableName(obj);
|
|
414
752
|
const jsonCols = [];
|
|
415
753
|
const booleanCols = [];
|
|
754
|
+
const autoNumberCols = [];
|
|
755
|
+
const tenancyDecl = obj?.tenancy;
|
|
756
|
+
let tenantField = null;
|
|
757
|
+
if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
|
|
758
|
+
const declared = String(tenancyDecl.tenantField);
|
|
759
|
+
if (obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, declared)) {
|
|
760
|
+
tenantField = declared;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (!tenantField) {
|
|
764
|
+
const hasOrgField = !!(obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, "organization_id"));
|
|
765
|
+
tenantField = hasOrgField ? "organization_id" : null;
|
|
766
|
+
}
|
|
416
767
|
if (obj.fields) {
|
|
417
768
|
for (const [name, field] of Object.entries(obj.fields)) {
|
|
418
769
|
const type = field.type || "string";
|
|
@@ -422,10 +773,19 @@ var SqlDriver = class {
|
|
|
422
773
|
if (type === "boolean") {
|
|
423
774
|
booleanCols.push(name);
|
|
424
775
|
}
|
|
776
|
+
if (type === "auto_number" || type === "autonumber") {
|
|
777
|
+
const fmt = typeof field.format === "string" && field.format ? field.format : "{0000}";
|
|
778
|
+
const m = fmt.match(/\{(0+)\}/);
|
|
779
|
+
const padWidth = m ? m[1].length : 4;
|
|
780
|
+
const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
|
|
781
|
+
autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
|
|
782
|
+
}
|
|
425
783
|
}
|
|
426
784
|
}
|
|
427
785
|
this.jsonFields[tableName] = jsonCols;
|
|
428
786
|
this.booleanFields[tableName] = booleanCols;
|
|
787
|
+
this.autoNumberFields[tableName] = autoNumberCols;
|
|
788
|
+
this.tenantFieldByTable[tableName] = tenantField;
|
|
429
789
|
let exists = await this.knex.schema.hasTable(tableName);
|
|
430
790
|
if (exists) {
|
|
431
791
|
const columnInfo = await this.knex(tableName).columnInfo();
|
|
@@ -525,6 +885,80 @@ var SqlDriver = class {
|
|
|
525
885
|
}
|
|
526
886
|
return builder;
|
|
527
887
|
}
|
|
888
|
+
/**
|
|
889
|
+
* Resolve the tenant column for the given object, if any.
|
|
890
|
+
*
|
|
891
|
+
* Lookup falls back to both the storage-mapped table name and the raw
|
|
892
|
+
* object name so callers that pass either form get the same answer.
|
|
893
|
+
* Returns `null` when the object has no tenant-isolation field.
|
|
894
|
+
*/
|
|
895
|
+
resolveTenantField(object) {
|
|
896
|
+
const tableName = StorageNameMapping.resolveTableName({ name: object });
|
|
897
|
+
const cached = this.tenantFieldByTable[tableName] ?? this.tenantFieldByTable[object];
|
|
898
|
+
return cached ?? null;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Apply a `WHERE tenant_field = ?` clause to the given query builder
|
|
902
|
+
* when:
|
|
903
|
+
* 1. `options.tenantId` is provided by the caller, AND
|
|
904
|
+
* 2. the object actually has a tenant-isolation field
|
|
905
|
+
* (`organization_id` by convention).
|
|
906
|
+
*
|
|
907
|
+
* Without a tenantId the call is treated as an unscoped/admin path —
|
|
908
|
+
* keeps legacy callers, seed scripts, and cross-org tooling working.
|
|
909
|
+
* This is the single chokepoint for read-side tenant isolation in the
|
|
910
|
+
* SQL driver; every CRUD method routes through it.
|
|
911
|
+
*/
|
|
912
|
+
applyTenantScope(builder, object, options) {
|
|
913
|
+
const tenantId = options?.tenantId;
|
|
914
|
+
if (tenantId === void 0 || tenantId === null || tenantId === "") return builder;
|
|
915
|
+
const field = this.resolveTenantField(object);
|
|
916
|
+
if (!field) return builder;
|
|
917
|
+
return builder.where(field, String(tenantId));
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Auto-inject the tenant column on insert rows when:
|
|
921
|
+
* 1. `options.tenantId` is provided, AND
|
|
922
|
+
* 2. the object has a tenant-isolation field, AND
|
|
923
|
+
* 3. the row does not already set that field.
|
|
924
|
+
*
|
|
925
|
+
* Explicit values are never overwritten — admins writing to a specific
|
|
926
|
+
* tenant via raw row data keep that authority.
|
|
927
|
+
*/
|
|
928
|
+
injectTenantOnInsert(object, row, options) {
|
|
929
|
+
const tenantId = options?.tenantId;
|
|
930
|
+
if (tenantId === void 0 || tenantId === null || tenantId === "") return;
|
|
931
|
+
const field = this.resolveTenantField(object);
|
|
932
|
+
if (!field) return;
|
|
933
|
+
if (row[field] === void 0 || row[field] === null || row[field] === "") {
|
|
934
|
+
row[field] = String(tenantId);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Surface writes that target a tenant-scoped object but don't carry a
|
|
939
|
+
* `tenantId`. These are almost always system / seed / admin paths that
|
|
940
|
+
* forgot to thread the active session context — easy to miss in code
|
|
941
|
+
* review and impossible to find after a breach.
|
|
942
|
+
*
|
|
943
|
+
* Throttled to one warning per `${object}:${op}` so background workers
|
|
944
|
+
* don't spam the log. Set `options.bypassTenantAudit = true` (or env
|
|
945
|
+
* `OS_TENANT_AUDIT=0`) to silence intentionally.
|
|
946
|
+
*/
|
|
947
|
+
auditMissingTenant(object, op, options) {
|
|
948
|
+
if (process.env.OS_TENANT_AUDIT === "0") return;
|
|
949
|
+
if (options?.bypassTenantAudit === true) return;
|
|
950
|
+
const tenantId = options?.tenantId;
|
|
951
|
+
if (tenantId !== void 0 && tenantId !== null && tenantId !== "") return;
|
|
952
|
+
const field = this.resolveTenantField(object);
|
|
953
|
+
if (!field) return;
|
|
954
|
+
const key = `${object}:${op}`;
|
|
955
|
+
if (this.tenantAuditWarned.has(key)) return;
|
|
956
|
+
this.tenantAuditWarned.add(key);
|
|
957
|
+
this.logger.warn(
|
|
958
|
+
`[tenant-audit] ${op} on tenant-scoped object "${object}" without options.tenantId \u2014 writes will not be tenant-isolated. Pass tenantId via ExecutionContext or set bypassTenantAudit:true to silence.`,
|
|
959
|
+
{ object, op, tenantField: field }
|
|
960
|
+
);
|
|
961
|
+
}
|
|
528
962
|
// ── Filter helpers ──────────────────────────────────────────────────────────
|
|
529
963
|
applyFilters(builder, filters) {
|
|
530
964
|
if (!filters) return;
|
|
@@ -763,6 +1197,7 @@ var SqlDriver = class {
|
|
|
763
1197
|
col = table.float(name);
|
|
764
1198
|
break;
|
|
765
1199
|
case "auto_number":
|
|
1200
|
+
case "autonumber":
|
|
766
1201
|
col = table.string(name);
|
|
767
1202
|
break;
|
|
768
1203
|
case "formula":
|
|
@@ -774,6 +1209,16 @@ var SqlDriver = class {
|
|
|
774
1209
|
if (col) {
|
|
775
1210
|
if (field.unique) col.unique();
|
|
776
1211
|
if (field.required) col.notNullable();
|
|
1212
|
+
if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
|
|
1213
|
+
col.defaultTo(this.knex.fn.now());
|
|
1214
|
+
} else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
|
|
1215
|
+
const dv = field.defaultValue;
|
|
1216
|
+
if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
|
|
1217
|
+
col.defaultTo(this.knex.fn.now());
|
|
1218
|
+
} else if (typeof dv !== "object") {
|
|
1219
|
+
col.defaultTo(dv);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
777
1222
|
}
|
|
778
1223
|
}
|
|
779
1224
|
// ── Database helpers ────────────────────────────────────────────────────────
|
|
@@ -847,10 +1292,28 @@ var SqlDriver = class {
|
|
|
847
1292
|
}
|
|
848
1293
|
// ── SQLite serialisation ────────────────────────────────────────────────────
|
|
849
1294
|
formatInput(object, data) {
|
|
850
|
-
|
|
1295
|
+
let copy = data;
|
|
1296
|
+
let copied = false;
|
|
1297
|
+
if (data && typeof data === "object") {
|
|
1298
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1299
|
+
for (const key of Object.keys(data)) {
|
|
1300
|
+
const v = data[key];
|
|
1301
|
+
if (typeof v === "string" && /^now\(\)$/i.test(v.trim())) {
|
|
1302
|
+
if (!copied) {
|
|
1303
|
+
copy = { ...data };
|
|
1304
|
+
copied = true;
|
|
1305
|
+
}
|
|
1306
|
+
copy[key] = now;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (!this.isSqlite) return copy;
|
|
851
1311
|
const fields = this.jsonFields[object];
|
|
852
|
-
if (!fields || fields.length === 0) return
|
|
853
|
-
|
|
1312
|
+
if (!fields || fields.length === 0) return copy;
|
|
1313
|
+
if (!copied) {
|
|
1314
|
+
copy = { ...copy };
|
|
1315
|
+
copied = true;
|
|
1316
|
+
}
|
|
854
1317
|
for (const field of fields) {
|
|
855
1318
|
if (copy[field] !== void 0 && typeof copy[field] === "object" && copy[field] !== null) {
|
|
856
1319
|
copy[field] = JSON.stringify(copy[field]);
|