@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.js
CHANGED
|
@@ -40,12 +40,64 @@ var import_system = require("@objectstack/spec/system");
|
|
|
40
40
|
var import_knex = __toESM(require("knex"));
|
|
41
41
|
var import_nanoid = require("nanoid");
|
|
42
42
|
var DEFAULT_ID_LENGTH = 16;
|
|
43
|
+
var SEQUENCES_TABLE = "_objectstack_sequences";
|
|
44
|
+
var GLOBAL_TENANT = "__global__";
|
|
43
45
|
var SqlDriver = class {
|
|
44
46
|
constructor(config) {
|
|
45
47
|
// IDataDriver metadata
|
|
46
48
|
this.name = "com.objectstack.driver.sql";
|
|
47
49
|
this.version = "1.0.0";
|
|
48
|
-
this.
|
|
50
|
+
this.jsonFields = {};
|
|
51
|
+
this.booleanFields = {};
|
|
52
|
+
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
53
|
+
/**
|
|
54
|
+
* Autonumber field configs per table, captured during initObjects.
|
|
55
|
+
*
|
|
56
|
+
* Each entry records:
|
|
57
|
+
* - `prefix` + `padWidth`: how to render the next value (`CTR-0007`)
|
|
58
|
+
* - `tenantField`: the column to scope the sequence by (defaults to
|
|
59
|
+
* `organization_id` if the object has that field, otherwise null →
|
|
60
|
+
* sequence is shared globally for that field)
|
|
61
|
+
*
|
|
62
|
+
* Numbering is backed by the `_objectstack_sequences` row keyed by
|
|
63
|
+
* `(object, tenant_id, field)`, not by scanning the data table on each
|
|
64
|
+
* insert. The sequence row is bootstrapped from the existing MAX on
|
|
65
|
+
* first use so legacy data is respected.
|
|
66
|
+
*/
|
|
67
|
+
this.autoNumberFields = {};
|
|
68
|
+
/** Whether the sequences table has been ensured this process. */
|
|
69
|
+
this.sequencesTableReady = false;
|
|
70
|
+
/** In-flight ensure promise; deduplicates concurrent first calls. */
|
|
71
|
+
this.sequencesTableEnsurePromise = null;
|
|
72
|
+
/**
|
|
73
|
+
* Per-table tenant-isolation column. Populated during `initObjects` by
|
|
74
|
+
* detecting an `organization_id` field. When set and the caller passes
|
|
75
|
+
* `DriverOptions.tenantId`, the driver automatically:
|
|
76
|
+
*
|
|
77
|
+
* - scopes reads/updates/deletes/aggregates to that tenant
|
|
78
|
+
* - injects `organization_id` on inserts that omit it
|
|
79
|
+
*
|
|
80
|
+
* If `tenantId` is absent (admin / seed / system path) no scope is
|
|
81
|
+
* applied — preserves backward compatibility for tools that legitimately
|
|
82
|
+
* need cross-tenant access. Tenant enforcement is therefore opt-in by
|
|
83
|
+
* the caller, not by the driver.
|
|
84
|
+
*/
|
|
85
|
+
this.tenantFieldByTable = {};
|
|
86
|
+
/** Throttle table for missing-tenantId warnings ({object}:{op}). */
|
|
87
|
+
this.tenantAuditWarned = /* @__PURE__ */ new Set();
|
|
88
|
+
/**
|
|
89
|
+
* Optional logger sink for security-audit warnings. Tests inject a spy;
|
|
90
|
+
* production callers wire in their preferred logger. Defaults to
|
|
91
|
+
* `console.warn` so warnings surface even without setup.
|
|
92
|
+
*/
|
|
93
|
+
this.logger = {
|
|
94
|
+
warn: (msg, meta) => console.warn(msg, meta ?? "")
|
|
95
|
+
};
|
|
96
|
+
this.config = config;
|
|
97
|
+
this.knex = (0, import_knex.default)(config);
|
|
98
|
+
}
|
|
99
|
+
get supports() {
|
|
100
|
+
return {
|
|
49
101
|
// Basic CRUD Operations
|
|
50
102
|
create: true,
|
|
51
103
|
read: true,
|
|
@@ -61,6 +113,12 @@ var SqlDriver = class {
|
|
|
61
113
|
// Query Operations
|
|
62
114
|
queryFilters: true,
|
|
63
115
|
queryAggregations: true,
|
|
116
|
+
/**
|
|
117
|
+
* Per-granularity native date bucket support. Granularities marked
|
|
118
|
+
* `false` (or absent) fall back to in-memory `bucketDateValue()` via
|
|
119
|
+
* `engine.findData` — see `buildDateBucketExpr()` for the SQL emitted.
|
|
120
|
+
*/
|
|
121
|
+
queryDateGranularity: this.dateGranularityCapabilities,
|
|
64
122
|
querySorting: true,
|
|
65
123
|
queryPagination: true,
|
|
66
124
|
queryWindowFunctions: true,
|
|
@@ -85,11 +143,6 @@ var SqlDriver = class {
|
|
|
85
143
|
preparedStatements: true,
|
|
86
144
|
queryCache: false
|
|
87
145
|
};
|
|
88
|
-
this.jsonFields = {};
|
|
89
|
-
this.booleanFields = {};
|
|
90
|
-
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
91
|
-
this.config = config;
|
|
92
|
-
this.knex = (0, import_knex.default)(config);
|
|
93
146
|
}
|
|
94
147
|
/** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
|
|
95
148
|
get isSqlite() {
|
|
@@ -106,6 +159,86 @@ var SqlDriver = class {
|
|
|
106
159
|
const c = this.config.client;
|
|
107
160
|
return c === "mysql" || c === "mysql2";
|
|
108
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Per-granularity native SQL bucket support, computed from dialect.
|
|
164
|
+
*
|
|
165
|
+
* Must match `bucketDateValue()` in @objectstack/objectql exactly:
|
|
166
|
+
* year → 'YYYY'
|
|
167
|
+
* month → 'YYYY-MM'
|
|
168
|
+
* day → 'YYYY-MM-DD'
|
|
169
|
+
* quarter → 'YYYY-Q[1-4]'
|
|
170
|
+
* week → 'YYYY-W[01-53]' (ISO-8601)
|
|
171
|
+
*
|
|
172
|
+
* Granularities not listed (or set to false) fall back to in-memory bucketing
|
|
173
|
+
* via engine.findData → applyInMemoryAggregation.
|
|
174
|
+
*/
|
|
175
|
+
get dateGranularityCapabilities() {
|
|
176
|
+
if (this.isPostgres) {
|
|
177
|
+
return { day: true, month: true, quarter: true, year: true, week: true };
|
|
178
|
+
}
|
|
179
|
+
if (this.isMysql) {
|
|
180
|
+
return { day: true, month: true, quarter: true, year: true, week: true };
|
|
181
|
+
}
|
|
182
|
+
if (this.isSqlite) {
|
|
183
|
+
return { day: true, month: true, quarter: true, year: true, week: false };
|
|
184
|
+
}
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Build SQL fragment + bindings for a date bucket expression.
|
|
189
|
+
* Returns `null` when the current dialect does not support the requested
|
|
190
|
+
* granularity — callers must fall back to in-memory bucketing.
|
|
191
|
+
*
|
|
192
|
+
* Exposed as `{sql, bindings}` (not `Knex.Raw`) so callers can both
|
|
193
|
+
* `groupByRaw()` and embed the same expression inside a `select() as alias`
|
|
194
|
+
* with correctly forwarded identifier bindings.
|
|
195
|
+
*/
|
|
196
|
+
buildDateBucketExpr(field, granularity) {
|
|
197
|
+
if (!this.dateGranularityCapabilities[granularity]) return null;
|
|
198
|
+
if (this.isPostgres) {
|
|
199
|
+
switch (granularity) {
|
|
200
|
+
case "year":
|
|
201
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY')`, bindings: [field] };
|
|
202
|
+
case "month":
|
|
203
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM')`, bindings: [field] };
|
|
204
|
+
case "day":
|
|
205
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY-MM-DD')`, bindings: [field] };
|
|
206
|
+
case "quarter":
|
|
207
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'YYYY"-Q"Q')`, bindings: [field] };
|
|
208
|
+
case "week":
|
|
209
|
+
return { sql: `to_char((??)::timestamptz AT TIME ZONE 'UTC', 'IYYY"-W"IW')`, bindings: [field] };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (this.isMysql) {
|
|
213
|
+
switch (granularity) {
|
|
214
|
+
case "year":
|
|
215
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y')`, bindings: [field] };
|
|
216
|
+
case "month":
|
|
217
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y-%m')`, bindings: [field] };
|
|
218
|
+
case "day":
|
|
219
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%Y-%m-%d')`, bindings: [field] };
|
|
220
|
+
case "quarter":
|
|
221
|
+
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] };
|
|
222
|
+
case "week":
|
|
223
|
+
return { sql: `date_format(convert_tz(??, @@session.time_zone, '+00:00'), '%x-W%v')`, bindings: [field] };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (this.isSqlite) {
|
|
227
|
+
switch (granularity) {
|
|
228
|
+
case "year":
|
|
229
|
+
return { sql: `strftime('%Y', ??)`, bindings: [field] };
|
|
230
|
+
case "month":
|
|
231
|
+
return { sql: `strftime('%Y-%m', ??)`, bindings: [field] };
|
|
232
|
+
case "day":
|
|
233
|
+
return { sql: `strftime('%Y-%m-%d', ??)`, bindings: [field] };
|
|
234
|
+
case "quarter":
|
|
235
|
+
return { sql: `(strftime('%Y', ??) || '-Q' || ((cast(strftime('%m', ??) as integer) - 1) / 3 + 1))`, bindings: [field, field] };
|
|
236
|
+
case "week":
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
109
242
|
// ===================================
|
|
110
243
|
// Lifecycle
|
|
111
244
|
// ===================================
|
|
@@ -128,6 +261,7 @@ var SqlDriver = class {
|
|
|
128
261
|
// ===================================
|
|
129
262
|
async find(object, query, options) {
|
|
130
263
|
const builder = this.getBuilder(object, options);
|
|
264
|
+
this.applyTenantScope(builder, object, options);
|
|
131
265
|
if (query.fields) {
|
|
132
266
|
builder.select(query.fields.map((f) => this.mapSortField(f)));
|
|
133
267
|
} else {
|
|
@@ -166,7 +300,9 @@ var SqlDriver = class {
|
|
|
166
300
|
}
|
|
167
301
|
async findOne(object, query, options) {
|
|
168
302
|
if (typeof query === "string" || typeof query === "number") {
|
|
169
|
-
const
|
|
303
|
+
const builder = this.getBuilder(object, options).where("id", query);
|
|
304
|
+
this.applyTenantScope(builder, object, options);
|
|
305
|
+
const res = await builder.first();
|
|
170
306
|
return this.formatOutput(object, res) || null;
|
|
171
307
|
}
|
|
172
308
|
if (query && typeof query === "object") {
|
|
@@ -194,13 +330,153 @@ var SqlDriver = class {
|
|
|
194
330
|
} else if (toInsert.id === void 0) {
|
|
195
331
|
toInsert.id = (0, import_nanoid.nanoid)(DEFAULT_ID_LENGTH);
|
|
196
332
|
}
|
|
333
|
+
this.auditMissingTenant(object, "create", options);
|
|
334
|
+
this.injectTenantOnInsert(object, toInsert, options);
|
|
335
|
+
await this.fillAutoNumberFields(object, toInsert, options);
|
|
197
336
|
const builder = this.getBuilder(object, options);
|
|
198
337
|
const formatted = this.formatInput(object, toInsert);
|
|
199
338
|
const result = await builder.insert(formatted).returning("*");
|
|
200
339
|
return this.formatOutput(object, result[0]);
|
|
201
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Ensure the sequence-counter table exists. Idempotent and cheap after
|
|
343
|
+
* the first call (cached via `sequencesTableReady`).
|
|
344
|
+
*/
|
|
345
|
+
async ensureSequencesTable() {
|
|
346
|
+
if (this.sequencesTableReady) return;
|
|
347
|
+
if (this.sequencesTableEnsurePromise) {
|
|
348
|
+
await this.sequencesTableEnsurePromise;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.sequencesTableEnsurePromise = (async () => {
|
|
352
|
+
const exists = await this.knex.schema.hasTable(SEQUENCES_TABLE);
|
|
353
|
+
if (!exists) {
|
|
354
|
+
try {
|
|
355
|
+
await this.knex.schema.createTable(SEQUENCES_TABLE, (t) => {
|
|
356
|
+
t.string("object").notNullable();
|
|
357
|
+
t.string("tenant_id").notNullable();
|
|
358
|
+
t.string("field").notNullable();
|
|
359
|
+
t.bigInteger("last_value").notNullable().defaultTo(0);
|
|
360
|
+
t.timestamp("updated_at").defaultTo(this.knex.fn.now());
|
|
361
|
+
t.primary(["object", "tenant_id", "field"]);
|
|
362
|
+
});
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const stillMissing = !await this.knex.schema.hasTable(SEQUENCES_TABLE);
|
|
365
|
+
if (stillMissing) throw err;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
this.sequencesTableReady = true;
|
|
369
|
+
})();
|
|
370
|
+
try {
|
|
371
|
+
await this.sequencesTableEnsurePromise;
|
|
372
|
+
} finally {
|
|
373
|
+
this.sequencesTableEnsurePromise = null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Bootstrap helper: scan the data table for the highest numeric suffix
|
|
378
|
+
* matching `prefix` (optionally scoped to a tenant). Used the first time
|
|
379
|
+
* a sequence row is created so legacy/seeded data continues monotonically.
|
|
380
|
+
*/
|
|
381
|
+
async scanMaxNumericTail(queryRunner, tableName, field, prefix, tenantField, tenantId) {
|
|
382
|
+
const escapedPrefix = prefix.replace(/([\\%_])/g, "\\$1");
|
|
383
|
+
let builder = queryRunner(tableName).select(field).where(field, "like", `${escapedPrefix}%`).whereNotNull(field);
|
|
384
|
+
if (tenantField && tenantId !== null) {
|
|
385
|
+
builder = builder.where(tenantField, tenantId);
|
|
386
|
+
}
|
|
387
|
+
const rows = await builder;
|
|
388
|
+
let maxN = 0;
|
|
389
|
+
for (const r of rows) {
|
|
390
|
+
const v = r[field];
|
|
391
|
+
if (typeof v !== "string") continue;
|
|
392
|
+
const tail = v.slice(prefix.length);
|
|
393
|
+
const n = parseInt(tail.replace(/[^0-9]/g, ""), 10);
|
|
394
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
395
|
+
}
|
|
396
|
+
return maxN;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Atomically reserve and return the next sequence value for
|
|
400
|
+
* `(object, tenantId, field)`. Bootstraps from the data-table MAX on
|
|
401
|
+
* first call so existing seeded records continue monotonically.
|
|
402
|
+
*
|
|
403
|
+
* Concurrency:
|
|
404
|
+
* - SQLite: a write transaction (`BEGIN IMMEDIATE` via knex) serializes
|
|
405
|
+
* all writers; safe in-process. Cross-process SQLite is out of scope.
|
|
406
|
+
* - Postgres/MySQL: `SELECT … FOR UPDATE` row lock ensures only one
|
|
407
|
+
* transaction reads-modifies-writes at a time. A PK-violation race on
|
|
408
|
+
* first insert is retried as an UPDATE.
|
|
409
|
+
*
|
|
410
|
+
* Gaps are tolerated by design — a rolled-back insert "burns" a number,
|
|
411
|
+
* matching standard sequence semantics.
|
|
412
|
+
*/
|
|
413
|
+
async getNextSequenceValue(object, tableName, field, prefix, tenantField, tenantId, parentTrx) {
|
|
414
|
+
await this.ensureSequencesTable();
|
|
415
|
+
const resolvedTenantId = tenantField && tenantId ? String(tenantId) : GLOBAL_TENANT;
|
|
416
|
+
const key = { object: tableName, tenant_id: resolvedTenantId, field };
|
|
417
|
+
const runner = parentTrx ?? this.knex;
|
|
418
|
+
return runner.transaction(async (trx) => {
|
|
419
|
+
let existing;
|
|
420
|
+
try {
|
|
421
|
+
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
|
|
422
|
+
} catch {
|
|
423
|
+
existing = await trx(SEQUENCES_TABLE).where(key).first();
|
|
424
|
+
}
|
|
425
|
+
if (!existing) {
|
|
426
|
+
const seedMax = await this.scanMaxNumericTail(
|
|
427
|
+
trx,
|
|
428
|
+
tableName,
|
|
429
|
+
field,
|
|
430
|
+
prefix,
|
|
431
|
+
tenantField,
|
|
432
|
+
resolvedTenantId === GLOBAL_TENANT ? null : resolvedTenantId
|
|
433
|
+
);
|
|
434
|
+
const initial = seedMax + 1;
|
|
435
|
+
try {
|
|
436
|
+
await trx(SEQUENCES_TABLE).insert({ ...key, last_value: initial });
|
|
437
|
+
return initial;
|
|
438
|
+
} catch (err) {
|
|
439
|
+
existing = await trx(SEQUENCES_TABLE).where(key).forUpdate().first();
|
|
440
|
+
if (!existing) throw err;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const next = Number(existing.last_value) + 1;
|
|
444
|
+
await trx(SEQUENCES_TABLE).where(key).update({ last_value: next, updated_at: this.knex.fn.now() });
|
|
445
|
+
return next;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* For each `auto_number` field on the object that the caller did not
|
|
450
|
+
* provide a value for, reserve the next sequence value scoped to the
|
|
451
|
+
* record's tenant (or globally if the object has no tenant field) and
|
|
452
|
+
* render `prefix + zero-padded(value)`.
|
|
453
|
+
*/
|
|
454
|
+
async fillAutoNumberFields(object, row, options) {
|
|
455
|
+
const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
|
|
456
|
+
const cfgs = this.autoNumberFields[tableName] || this.autoNumberFields[object];
|
|
457
|
+
if (!cfgs || cfgs.length === 0) return;
|
|
458
|
+
const parentTrx = options?.transaction;
|
|
459
|
+
for (const cfg of cfgs) {
|
|
460
|
+
if (row[cfg.name] !== void 0 && row[cfg.name] !== null && row[cfg.name] !== "") continue;
|
|
461
|
+
const rowTenant = cfg.tenantField ? row[cfg.tenantField] : void 0;
|
|
462
|
+
const optTenant = options?.tenantId;
|
|
463
|
+
const tenantId = rowTenant != null && rowTenant !== "" ? String(rowTenant) : optTenant != null && optTenant !== "" ? String(optTenant) : null;
|
|
464
|
+
const next = await this.getNextSequenceValue(
|
|
465
|
+
object,
|
|
466
|
+
tableName,
|
|
467
|
+
cfg.name,
|
|
468
|
+
cfg.prefix,
|
|
469
|
+
cfg.tenantField,
|
|
470
|
+
tenantId,
|
|
471
|
+
parentTrx
|
|
472
|
+
);
|
|
473
|
+
row[cfg.name] = `${cfg.prefix}${String(next).padStart(cfg.padWidth, "0")}`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
202
476
|
async update(object, id, data, options) {
|
|
203
|
-
|
|
477
|
+
this.auditMissingTenant(object, "update", options);
|
|
478
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
479
|
+
this.applyTenantScope(builder, object, options);
|
|
204
480
|
const formatted = this.formatInput(object, data);
|
|
205
481
|
if (this.tablesWithTimestamps.has(object)) {
|
|
206
482
|
if (this.isSqlite) {
|
|
@@ -210,8 +486,10 @@ var SqlDriver = class {
|
|
|
210
486
|
formatted.updated_at = this.knex.fn.now();
|
|
211
487
|
}
|
|
212
488
|
}
|
|
213
|
-
await builder.
|
|
214
|
-
const
|
|
489
|
+
await builder.update(formatted);
|
|
490
|
+
const readback = this.getBuilder(object, options).where("id", id);
|
|
491
|
+
this.applyTenantScope(readback, object, options);
|
|
492
|
+
const updated = await readback.first();
|
|
215
493
|
return this.formatOutput(object, updated) || null;
|
|
216
494
|
}
|
|
217
495
|
async upsert(object, data, conflictKeys, options) {
|
|
@@ -222,22 +500,33 @@ var SqlDriver = class {
|
|
|
222
500
|
} else if (toUpsert.id === void 0) {
|
|
223
501
|
toUpsert.id = (0, import_nanoid.nanoid)(DEFAULT_ID_LENGTH);
|
|
224
502
|
}
|
|
503
|
+
this.auditMissingTenant(object, "upsert", options);
|
|
504
|
+
this.injectTenantOnInsert(object, toUpsert, options);
|
|
505
|
+
await this.fillAutoNumberFields(object, toUpsert, options);
|
|
225
506
|
const formatted = this.formatInput(object, toUpsert);
|
|
226
507
|
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
|
|
227
508
|
const builder = this.getBuilder(object, options);
|
|
228
509
|
await builder.insert(formatted).onConflict(mergeKeys).merge();
|
|
229
|
-
const
|
|
510
|
+
const readback = this.getBuilder(object, options).where("id", toUpsert.id);
|
|
511
|
+
this.applyTenantScope(readback, object, options);
|
|
512
|
+
const result = await readback.first();
|
|
230
513
|
return this.formatOutput(object, result) || toUpsert;
|
|
231
514
|
}
|
|
232
515
|
async delete(object, id, options) {
|
|
233
|
-
|
|
234
|
-
const
|
|
516
|
+
this.auditMissingTenant(object, "delete", options);
|
|
517
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
518
|
+
this.applyTenantScope(builder, object, options);
|
|
519
|
+
const count = await builder.delete();
|
|
235
520
|
return count > 0;
|
|
236
521
|
}
|
|
237
522
|
// ===================================
|
|
238
523
|
// Bulk & Batch Operations
|
|
239
524
|
// ===================================
|
|
240
525
|
async bulkCreate(object, data, options) {
|
|
526
|
+
this.auditMissingTenant(object, "bulkCreate", options);
|
|
527
|
+
for (const row of data) {
|
|
528
|
+
if (row && typeof row === "object") this.injectTenantOnInsert(object, row, options);
|
|
529
|
+
}
|
|
241
530
|
const builder = this.getBuilder(object, options);
|
|
242
531
|
return await builder.insert(data).returning("*");
|
|
243
532
|
}
|
|
@@ -255,23 +544,30 @@ var SqlDriver = class {
|
|
|
255
544
|
return results;
|
|
256
545
|
}
|
|
257
546
|
async bulkDelete(object, ids, options) {
|
|
258
|
-
|
|
259
|
-
|
|
547
|
+
this.auditMissingTenant(object, "bulkDelete", options);
|
|
548
|
+
const builder = this.getBuilder(object, options).whereIn("id", ids);
|
|
549
|
+
this.applyTenantScope(builder, object, options);
|
|
550
|
+
await builder.delete();
|
|
260
551
|
}
|
|
261
552
|
async updateMany(object, query, data, options) {
|
|
553
|
+
this.auditMissingTenant(object, "updateMany", options);
|
|
262
554
|
const builder = this.getBuilder(object, options);
|
|
555
|
+
this.applyTenantScope(builder, object, options);
|
|
263
556
|
if (query.where) this.applyFilters(builder, query.where);
|
|
264
557
|
const count = await builder.update(data);
|
|
265
558
|
return count || 0;
|
|
266
559
|
}
|
|
267
560
|
async deleteMany(object, query, options) {
|
|
561
|
+
this.auditMissingTenant(object, "deleteMany", options);
|
|
268
562
|
const builder = this.getBuilder(object, options);
|
|
563
|
+
this.applyTenantScope(builder, object, options);
|
|
269
564
|
if (query.where) this.applyFilters(builder, query.where);
|
|
270
565
|
const count = await builder.delete();
|
|
271
566
|
return count || 0;
|
|
272
567
|
}
|
|
273
568
|
async count(object, query, options) {
|
|
274
569
|
const builder = this.getBuilder(object, options);
|
|
570
|
+
this.applyTenantScope(builder, object, options);
|
|
275
571
|
if (query?.where) {
|
|
276
572
|
this.applyFilters(builder, query.where);
|
|
277
573
|
}
|
|
@@ -285,6 +581,22 @@ var SqlDriver = class {
|
|
|
285
581
|
// ===================================
|
|
286
582
|
// Raw Execution
|
|
287
583
|
// ===================================
|
|
584
|
+
/**
|
|
585
|
+
* Run a raw SQL string or knex builder through the underlying knex
|
|
586
|
+
* connection.
|
|
587
|
+
*
|
|
588
|
+
* ⚠️ **Tenant isolation bypass.** Unlike `find`/`update`/`delete` etc.,
|
|
589
|
+
* raw `execute()` does NOT inject the `organization_id` predicate. The
|
|
590
|
+
* caller is responsible for either:
|
|
591
|
+
* - inlining the tenant filter into the SQL (`WHERE organization_id = ?`),
|
|
592
|
+
* - or restricting `execute()` to genuinely global queries
|
|
593
|
+
* (schema introspection, sys_* tables that opt out of tenancy).
|
|
594
|
+
*
|
|
595
|
+
* Prefer the typed CRUD APIs whenever the operation can be expressed
|
|
596
|
+
* through them — they handle tenancy, soft-delete, and audit warnings
|
|
597
|
+
* automatically. See `README.md > Tenant Isolation` for the full bypass
|
|
598
|
+
* matrix.
|
|
599
|
+
*/
|
|
288
600
|
async execute(command, params, options) {
|
|
289
601
|
if (typeof command !== "string") {
|
|
290
602
|
return command;
|
|
@@ -319,13 +631,30 @@ var SqlDriver = class {
|
|
|
319
631
|
// ===================================
|
|
320
632
|
async aggregate(object, query, options) {
|
|
321
633
|
const builder = this.getBuilder(object, options);
|
|
634
|
+
this.applyTenantScope(builder, object, options);
|
|
322
635
|
if (query.where) {
|
|
323
636
|
this.applyFilters(builder, query.where);
|
|
324
637
|
}
|
|
325
638
|
if (query.groupBy) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
639
|
+
for (const g of query.groupBy) {
|
|
640
|
+
if (typeof g === "string") {
|
|
641
|
+
builder.groupBy(g);
|
|
642
|
+
builder.select(g);
|
|
643
|
+
} else if (g && typeof g === "object" && g.field) {
|
|
644
|
+
if (g.dateGranularity) {
|
|
645
|
+
const bucket = this.buildDateBucketExpr(g.field, g.dateGranularity);
|
|
646
|
+
if (!bucket) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
`SqlDriver: dateGranularity '${g.dateGranularity}' not supported on dialect '${this.config.client}'. Engine must fall back to in-memory bucketing.`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
builder.groupByRaw(bucket.sql, bucket.bindings);
|
|
652
|
+
builder.select(this.knex.raw(`${bucket.sql} as ??`, [...bucket.bindings, g.field]));
|
|
653
|
+
} else {
|
|
654
|
+
builder.groupBy(g.field);
|
|
655
|
+
builder.select(g.field);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
329
658
|
}
|
|
330
659
|
}
|
|
331
660
|
const aggregates = query.aggregations || query.aggregate;
|
|
@@ -333,10 +662,19 @@ var SqlDriver = class {
|
|
|
333
662
|
for (const agg of aggregates) {
|
|
334
663
|
const funcName = agg.function || agg.func;
|
|
335
664
|
const rawFunc = this.mapAggregateFunc(funcName);
|
|
665
|
+
const fieldExpr = agg.field ?? "*";
|
|
336
666
|
if (agg.alias) {
|
|
337
|
-
|
|
667
|
+
if (fieldExpr === "*") {
|
|
668
|
+
builder.select(this.knex.raw(`${rawFunc}(*) as ??`, [agg.alias]));
|
|
669
|
+
} else {
|
|
670
|
+
builder.select(this.knex.raw(`${rawFunc}(??) as ??`, [fieldExpr, agg.alias]));
|
|
671
|
+
}
|
|
338
672
|
} else {
|
|
339
|
-
|
|
673
|
+
if (fieldExpr === "*") {
|
|
674
|
+
builder.select(this.knex.raw(`${rawFunc}(*)`));
|
|
675
|
+
} else {
|
|
676
|
+
builder.select(this.knex.raw(`${rawFunc}(??)`, [fieldExpr]));
|
|
677
|
+
}
|
|
340
678
|
}
|
|
341
679
|
}
|
|
342
680
|
}
|
|
@@ -450,6 +788,19 @@ var SqlDriver = class {
|
|
|
450
788
|
const tableName = import_system.StorageNameMapping.resolveTableName(obj);
|
|
451
789
|
const jsonCols = [];
|
|
452
790
|
const booleanCols = [];
|
|
791
|
+
const autoNumberCols = [];
|
|
792
|
+
const tenancyDecl = obj?.tenancy;
|
|
793
|
+
let tenantField = null;
|
|
794
|
+
if (tenancyDecl && tenancyDecl.enabled !== false && tenancyDecl.tenantField) {
|
|
795
|
+
const declared = String(tenancyDecl.tenantField);
|
|
796
|
+
if (obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, declared)) {
|
|
797
|
+
tenantField = declared;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (!tenantField) {
|
|
801
|
+
const hasOrgField = !!(obj.fields && Object.prototype.hasOwnProperty.call(obj.fields, "organization_id"));
|
|
802
|
+
tenantField = hasOrgField ? "organization_id" : null;
|
|
803
|
+
}
|
|
453
804
|
if (obj.fields) {
|
|
454
805
|
for (const [name, field] of Object.entries(obj.fields)) {
|
|
455
806
|
const type = field.type || "string";
|
|
@@ -459,10 +810,19 @@ var SqlDriver = class {
|
|
|
459
810
|
if (type === "boolean") {
|
|
460
811
|
booleanCols.push(name);
|
|
461
812
|
}
|
|
813
|
+
if (type === "auto_number" || type === "autonumber") {
|
|
814
|
+
const fmt = typeof field.format === "string" && field.format ? field.format : "{0000}";
|
|
815
|
+
const m = fmt.match(/\{(0+)\}/);
|
|
816
|
+
const padWidth = m ? m[1].length : 4;
|
|
817
|
+
const prefix = m ? fmt.slice(0, m.index ?? 0) : fmt;
|
|
818
|
+
autoNumberCols.push({ name, format: fmt, prefix, padWidth, tenantField });
|
|
819
|
+
}
|
|
462
820
|
}
|
|
463
821
|
}
|
|
464
822
|
this.jsonFields[tableName] = jsonCols;
|
|
465
823
|
this.booleanFields[tableName] = booleanCols;
|
|
824
|
+
this.autoNumberFields[tableName] = autoNumberCols;
|
|
825
|
+
this.tenantFieldByTable[tableName] = tenantField;
|
|
466
826
|
let exists = await this.knex.schema.hasTable(tableName);
|
|
467
827
|
if (exists) {
|
|
468
828
|
const columnInfo = await this.knex(tableName).columnInfo();
|
|
@@ -562,6 +922,80 @@ var SqlDriver = class {
|
|
|
562
922
|
}
|
|
563
923
|
return builder;
|
|
564
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* Resolve the tenant column for the given object, if any.
|
|
927
|
+
*
|
|
928
|
+
* Lookup falls back to both the storage-mapped table name and the raw
|
|
929
|
+
* object name so callers that pass either form get the same answer.
|
|
930
|
+
* Returns `null` when the object has no tenant-isolation field.
|
|
931
|
+
*/
|
|
932
|
+
resolveTenantField(object) {
|
|
933
|
+
const tableName = import_system.StorageNameMapping.resolveTableName({ name: object });
|
|
934
|
+
const cached = this.tenantFieldByTable[tableName] ?? this.tenantFieldByTable[object];
|
|
935
|
+
return cached ?? null;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Apply a `WHERE tenant_field = ?` clause to the given query builder
|
|
939
|
+
* when:
|
|
940
|
+
* 1. `options.tenantId` is provided by the caller, AND
|
|
941
|
+
* 2. the object actually has a tenant-isolation field
|
|
942
|
+
* (`organization_id` by convention).
|
|
943
|
+
*
|
|
944
|
+
* Without a tenantId the call is treated as an unscoped/admin path —
|
|
945
|
+
* keeps legacy callers, seed scripts, and cross-org tooling working.
|
|
946
|
+
* This is the single chokepoint for read-side tenant isolation in the
|
|
947
|
+
* SQL driver; every CRUD method routes through it.
|
|
948
|
+
*/
|
|
949
|
+
applyTenantScope(builder, object, options) {
|
|
950
|
+
const tenantId = options?.tenantId;
|
|
951
|
+
if (tenantId === void 0 || tenantId === null || tenantId === "") return builder;
|
|
952
|
+
const field = this.resolveTenantField(object);
|
|
953
|
+
if (!field) return builder;
|
|
954
|
+
return builder.where(field, String(tenantId));
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Auto-inject the tenant column on insert rows when:
|
|
958
|
+
* 1. `options.tenantId` is provided, AND
|
|
959
|
+
* 2. the object has a tenant-isolation field, AND
|
|
960
|
+
* 3. the row does not already set that field.
|
|
961
|
+
*
|
|
962
|
+
* Explicit values are never overwritten — admins writing to a specific
|
|
963
|
+
* tenant via raw row data keep that authority.
|
|
964
|
+
*/
|
|
965
|
+
injectTenantOnInsert(object, row, options) {
|
|
966
|
+
const tenantId = options?.tenantId;
|
|
967
|
+
if (tenantId === void 0 || tenantId === null || tenantId === "") return;
|
|
968
|
+
const field = this.resolveTenantField(object);
|
|
969
|
+
if (!field) return;
|
|
970
|
+
if (row[field] === void 0 || row[field] === null || row[field] === "") {
|
|
971
|
+
row[field] = String(tenantId);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Surface writes that target a tenant-scoped object but don't carry a
|
|
976
|
+
* `tenantId`. These are almost always system / seed / admin paths that
|
|
977
|
+
* forgot to thread the active session context — easy to miss in code
|
|
978
|
+
* review and impossible to find after a breach.
|
|
979
|
+
*
|
|
980
|
+
* Throttled to one warning per `${object}:${op}` so background workers
|
|
981
|
+
* don't spam the log. Set `options.bypassTenantAudit = true` (or env
|
|
982
|
+
* `OS_TENANT_AUDIT=0`) to silence intentionally.
|
|
983
|
+
*/
|
|
984
|
+
auditMissingTenant(object, op, options) {
|
|
985
|
+
if (process.env.OS_TENANT_AUDIT === "0") return;
|
|
986
|
+
if (options?.bypassTenantAudit === true) return;
|
|
987
|
+
const tenantId = options?.tenantId;
|
|
988
|
+
if (tenantId !== void 0 && tenantId !== null && tenantId !== "") return;
|
|
989
|
+
const field = this.resolveTenantField(object);
|
|
990
|
+
if (!field) return;
|
|
991
|
+
const key = `${object}:${op}`;
|
|
992
|
+
if (this.tenantAuditWarned.has(key)) return;
|
|
993
|
+
this.tenantAuditWarned.add(key);
|
|
994
|
+
this.logger.warn(
|
|
995
|
+
`[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.`,
|
|
996
|
+
{ object, op, tenantField: field }
|
|
997
|
+
);
|
|
998
|
+
}
|
|
565
999
|
// ── Filter helpers ──────────────────────────────────────────────────────────
|
|
566
1000
|
applyFilters(builder, filters) {
|
|
567
1001
|
if (!filters) return;
|
|
@@ -800,6 +1234,7 @@ var SqlDriver = class {
|
|
|
800
1234
|
col = table.float(name);
|
|
801
1235
|
break;
|
|
802
1236
|
case "auto_number":
|
|
1237
|
+
case "autonumber":
|
|
803
1238
|
col = table.string(name);
|
|
804
1239
|
break;
|
|
805
1240
|
case "formula":
|
|
@@ -811,6 +1246,16 @@ var SqlDriver = class {
|
|
|
811
1246
|
if (col) {
|
|
812
1247
|
if (field.unique) col.unique();
|
|
813
1248
|
if (field.required) col.notNullable();
|
|
1249
|
+
if ((type === "datetime" || type === "date" || type === "time") && typeof field.defaultValue === "string" && /^now\(\)$/i.test(field.defaultValue.trim())) {
|
|
1250
|
+
col.defaultTo(this.knex.fn.now());
|
|
1251
|
+
} else if (field.defaultValue !== void 0 && field.defaultValue !== null) {
|
|
1252
|
+
const dv = field.defaultValue;
|
|
1253
|
+
if (typeof dv === "string" && /^now\(\)$/i.test(dv.trim())) {
|
|
1254
|
+
col.defaultTo(this.knex.fn.now());
|
|
1255
|
+
} else if (typeof dv !== "object") {
|
|
1256
|
+
col.defaultTo(dv);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
814
1259
|
}
|
|
815
1260
|
}
|
|
816
1261
|
// ── Database helpers ────────────────────────────────────────────────────────
|
|
@@ -884,10 +1329,28 @@ var SqlDriver = class {
|
|
|
884
1329
|
}
|
|
885
1330
|
// ── SQLite serialisation ────────────────────────────────────────────────────
|
|
886
1331
|
formatInput(object, data) {
|
|
887
|
-
|
|
1332
|
+
let copy = data;
|
|
1333
|
+
let copied = false;
|
|
1334
|
+
if (data && typeof data === "object") {
|
|
1335
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1336
|
+
for (const key of Object.keys(data)) {
|
|
1337
|
+
const v = data[key];
|
|
1338
|
+
if (typeof v === "string" && /^now\(\)$/i.test(v.trim())) {
|
|
1339
|
+
if (!copied) {
|
|
1340
|
+
copy = { ...data };
|
|
1341
|
+
copied = true;
|
|
1342
|
+
}
|
|
1343
|
+
copy[key] = now;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (!this.isSqlite) return copy;
|
|
888
1348
|
const fields = this.jsonFields[object];
|
|
889
|
-
if (!fields || fields.length === 0) return
|
|
890
|
-
|
|
1349
|
+
if (!fields || fields.length === 0) return copy;
|
|
1350
|
+
if (!copied) {
|
|
1351
|
+
copy = { ...copy };
|
|
1352
|
+
copied = true;
|
|
1353
|
+
}
|
|
891
1354
|
for (const field of fields) {
|
|
892
1355
|
if (copy[field] !== void 0 && typeof copy[field] === "object" && copy[field] !== null) {
|
|
893
1356
|
copy[field] = JSON.stringify(copy[field]);
|