@objectstack/driver-sql 4.0.4 → 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 +61 -4
- package/dist/index.d.mts +175 -2
- package/dist/index.d.ts +175 -2
- package/dist/index.js +488 -24
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +488 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +35 -9
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -52
- package/src/index.ts +0 -30
- package/src/sql-driver-advanced.test.ts +0 -499
- package/src/sql-driver-introspection.test.ts +0 -164
- package/src/sql-driver-queryast.test.ts +0 -243
- package/src/sql-driver-schema.test.ts +0 -313
- package/src/sql-driver.test.ts +0 -108
- package/src/sql-driver.ts +0 -1388
- package/tsconfig.json +0 -32
package/dist/index.js
CHANGED
|
@@ -36,15 +36,68 @@ __export(index_exports, {
|
|
|
36
36
|
module.exports = __toCommonJS(index_exports);
|
|
37
37
|
|
|
38
38
|
// src/sql-driver.ts
|
|
39
|
+
var import_system = require("@objectstack/spec/system");
|
|
39
40
|
var import_knex = __toESM(require("knex"));
|
|
40
41
|
var import_nanoid = require("nanoid");
|
|
41
42
|
var DEFAULT_ID_LENGTH = 16;
|
|
43
|
+
var SEQUENCES_TABLE = "_objectstack_sequences";
|
|
44
|
+
var GLOBAL_TENANT = "__global__";
|
|
42
45
|
var SqlDriver = class {
|
|
43
46
|
constructor(config) {
|
|
44
47
|
// IDataDriver metadata
|
|
45
48
|
this.name = "com.objectstack.driver.sql";
|
|
46
49
|
this.version = "1.0.0";
|
|
47
|
-
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 {
|
|
48
101
|
// Basic CRUD Operations
|
|
49
102
|
create: true,
|
|
50
103
|
read: true,
|
|
@@ -60,6 +113,12 @@ var SqlDriver = class {
|
|
|
60
113
|
// Query Operations
|
|
61
114
|
queryFilters: true,
|
|
62
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,
|
|
63
122
|
querySorting: true,
|
|
64
123
|
queryPagination: true,
|
|
65
124
|
queryWindowFunctions: true,
|
|
@@ -84,11 +143,6 @@ var SqlDriver = class {
|
|
|
84
143
|
preparedStatements: true,
|
|
85
144
|
queryCache: false
|
|
86
145
|
};
|
|
87
|
-
this.jsonFields = {};
|
|
88
|
-
this.booleanFields = {};
|
|
89
|
-
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
90
|
-
this.config = config;
|
|
91
|
-
this.knex = (0, import_knex.default)(config);
|
|
92
146
|
}
|
|
93
147
|
/** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
|
|
94
148
|
get isSqlite() {
|
|
@@ -105,6 +159,86 @@ var SqlDriver = class {
|
|
|
105
159
|
const c = this.config.client;
|
|
106
160
|
return c === "mysql" || c === "mysql2";
|
|
107
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
|
+
}
|
|
108
242
|
// ===================================
|
|
109
243
|
// Lifecycle
|
|
110
244
|
// ===================================
|
|
@@ -127,6 +261,7 @@ var SqlDriver = class {
|
|
|
127
261
|
// ===================================
|
|
128
262
|
async find(object, query, options) {
|
|
129
263
|
const builder = this.getBuilder(object, options);
|
|
264
|
+
this.applyTenantScope(builder, object, options);
|
|
130
265
|
if (query.fields) {
|
|
131
266
|
builder.select(query.fields.map((f) => this.mapSortField(f)));
|
|
132
267
|
} else {
|
|
@@ -165,7 +300,9 @@ var SqlDriver = class {
|
|
|
165
300
|
}
|
|
166
301
|
async findOne(object, query, options) {
|
|
167
302
|
if (typeof query === "string" || typeof query === "number") {
|
|
168
|
-
const
|
|
303
|
+
const builder = this.getBuilder(object, options).where("id", query);
|
|
304
|
+
this.applyTenantScope(builder, object, options);
|
|
305
|
+
const res = await builder.first();
|
|
169
306
|
return this.formatOutput(object, res) || null;
|
|
170
307
|
}
|
|
171
308
|
if (query && typeof query === "object") {
|
|
@@ -193,13 +330,153 @@ var SqlDriver = class {
|
|
|
193
330
|
} else if (toInsert.id === void 0) {
|
|
194
331
|
toInsert.id = (0, import_nanoid.nanoid)(DEFAULT_ID_LENGTH);
|
|
195
332
|
}
|
|
333
|
+
this.auditMissingTenant(object, "create", options);
|
|
334
|
+
this.injectTenantOnInsert(object, toInsert, options);
|
|
335
|
+
await this.fillAutoNumberFields(object, toInsert, options);
|
|
196
336
|
const builder = this.getBuilder(object, options);
|
|
197
337
|
const formatted = this.formatInput(object, toInsert);
|
|
198
338
|
const result = await builder.insert(formatted).returning("*");
|
|
199
339
|
return this.formatOutput(object, result[0]);
|
|
200
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
|
+
}
|
|
201
476
|
async update(object, id, data, options) {
|
|
202
|
-
|
|
477
|
+
this.auditMissingTenant(object, "update", options);
|
|
478
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
479
|
+
this.applyTenantScope(builder, object, options);
|
|
203
480
|
const formatted = this.formatInput(object, data);
|
|
204
481
|
if (this.tablesWithTimestamps.has(object)) {
|
|
205
482
|
if (this.isSqlite) {
|
|
@@ -209,8 +486,10 @@ var SqlDriver = class {
|
|
|
209
486
|
formatted.updated_at = this.knex.fn.now();
|
|
210
487
|
}
|
|
211
488
|
}
|
|
212
|
-
await builder.
|
|
213
|
-
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();
|
|
214
493
|
return this.formatOutput(object, updated) || null;
|
|
215
494
|
}
|
|
216
495
|
async upsert(object, data, conflictKeys, options) {
|
|
@@ -221,22 +500,33 @@ var SqlDriver = class {
|
|
|
221
500
|
} else if (toUpsert.id === void 0) {
|
|
222
501
|
toUpsert.id = (0, import_nanoid.nanoid)(DEFAULT_ID_LENGTH);
|
|
223
502
|
}
|
|
503
|
+
this.auditMissingTenant(object, "upsert", options);
|
|
504
|
+
this.injectTenantOnInsert(object, toUpsert, options);
|
|
505
|
+
await this.fillAutoNumberFields(object, toUpsert, options);
|
|
224
506
|
const formatted = this.formatInput(object, toUpsert);
|
|
225
507
|
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
|
|
226
508
|
const builder = this.getBuilder(object, options);
|
|
227
509
|
await builder.insert(formatted).onConflict(mergeKeys).merge();
|
|
228
|
-
const
|
|
510
|
+
const readback = this.getBuilder(object, options).where("id", toUpsert.id);
|
|
511
|
+
this.applyTenantScope(readback, object, options);
|
|
512
|
+
const result = await readback.first();
|
|
229
513
|
return this.formatOutput(object, result) || toUpsert;
|
|
230
514
|
}
|
|
231
515
|
async delete(object, id, options) {
|
|
232
|
-
|
|
233
|
-
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();
|
|
234
520
|
return count > 0;
|
|
235
521
|
}
|
|
236
522
|
// ===================================
|
|
237
523
|
// Bulk & Batch Operations
|
|
238
524
|
// ===================================
|
|
239
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
|
+
}
|
|
240
530
|
const builder = this.getBuilder(object, options);
|
|
241
531
|
return await builder.insert(data).returning("*");
|
|
242
532
|
}
|
|
@@ -254,23 +544,30 @@ var SqlDriver = class {
|
|
|
254
544
|
return results;
|
|
255
545
|
}
|
|
256
546
|
async bulkDelete(object, ids, options) {
|
|
257
|
-
|
|
258
|
-
|
|
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();
|
|
259
551
|
}
|
|
260
552
|
async updateMany(object, query, data, options) {
|
|
553
|
+
this.auditMissingTenant(object, "updateMany", options);
|
|
261
554
|
const builder = this.getBuilder(object, options);
|
|
555
|
+
this.applyTenantScope(builder, object, options);
|
|
262
556
|
if (query.where) this.applyFilters(builder, query.where);
|
|
263
557
|
const count = await builder.update(data);
|
|
264
558
|
return count || 0;
|
|
265
559
|
}
|
|
266
560
|
async deleteMany(object, query, options) {
|
|
561
|
+
this.auditMissingTenant(object, "deleteMany", options);
|
|
267
562
|
const builder = this.getBuilder(object, options);
|
|
563
|
+
this.applyTenantScope(builder, object, options);
|
|
268
564
|
if (query.where) this.applyFilters(builder, query.where);
|
|
269
565
|
const count = await builder.delete();
|
|
270
566
|
return count || 0;
|
|
271
567
|
}
|
|
272
568
|
async count(object, query, options) {
|
|
273
569
|
const builder = this.getBuilder(object, options);
|
|
570
|
+
this.applyTenantScope(builder, object, options);
|
|
274
571
|
if (query?.where) {
|
|
275
572
|
this.applyFilters(builder, query.where);
|
|
276
573
|
}
|
|
@@ -284,6 +581,22 @@ var SqlDriver = class {
|
|
|
284
581
|
// ===================================
|
|
285
582
|
// Raw Execution
|
|
286
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
|
+
*/
|
|
287
600
|
async execute(command, params, options) {
|
|
288
601
|
if (typeof command !== "string") {
|
|
289
602
|
return command;
|
|
@@ -318,13 +631,30 @@ var SqlDriver = class {
|
|
|
318
631
|
// ===================================
|
|
319
632
|
async aggregate(object, query, options) {
|
|
320
633
|
const builder = this.getBuilder(object, options);
|
|
634
|
+
this.applyTenantScope(builder, object, options);
|
|
321
635
|
if (query.where) {
|
|
322
636
|
this.applyFilters(builder, query.where);
|
|
323
637
|
}
|
|
324
638
|
if (query.groupBy) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
+
}
|
|
328
658
|
}
|
|
329
659
|
}
|
|
330
660
|
const aggregates = query.aggregations || query.aggregate;
|
|
@@ -332,10 +662,19 @@ var SqlDriver = class {
|
|
|
332
662
|
for (const agg of aggregates) {
|
|
333
663
|
const funcName = agg.function || agg.func;
|
|
334
664
|
const rawFunc = this.mapAggregateFunc(funcName);
|
|
665
|
+
const fieldExpr = agg.field ?? "*";
|
|
335
666
|
if (agg.alias) {
|
|
336
|
-
|
|
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
|
+
}
|
|
337
672
|
} else {
|
|
338
|
-
|
|
673
|
+
if (fieldExpr === "*") {
|
|
674
|
+
builder.select(this.knex.raw(`${rawFunc}(*)`));
|
|
675
|
+
} else {
|
|
676
|
+
builder.select(this.knex.raw(`${rawFunc}(??)`, [fieldExpr]));
|
|
677
|
+
}
|
|
339
678
|
}
|
|
340
679
|
}
|
|
341
680
|
}
|
|
@@ -446,9 +785,22 @@ var SqlDriver = class {
|
|
|
446
785
|
async initObjects(objects) {
|
|
447
786
|
await this.ensureDatabaseExists();
|
|
448
787
|
for (const obj of objects) {
|
|
449
|
-
const tableName =
|
|
788
|
+
const tableName = import_system.StorageNameMapping.resolveTableName(obj);
|
|
450
789
|
const jsonCols = [];
|
|
451
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
|
+
}
|
|
452
804
|
if (obj.fields) {
|
|
453
805
|
for (const [name, field] of Object.entries(obj.fields)) {
|
|
454
806
|
const type = field.type || "string";
|
|
@@ -458,10 +810,19 @@ var SqlDriver = class {
|
|
|
458
810
|
if (type === "boolean") {
|
|
459
811
|
booleanCols.push(name);
|
|
460
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
|
+
}
|
|
461
820
|
}
|
|
462
821
|
}
|
|
463
822
|
this.jsonFields[tableName] = jsonCols;
|
|
464
823
|
this.booleanFields[tableName] = booleanCols;
|
|
824
|
+
this.autoNumberFields[tableName] = autoNumberCols;
|
|
825
|
+
this.tenantFieldByTable[tableName] = tenantField;
|
|
465
826
|
let exists = await this.knex.schema.hasTable(tableName);
|
|
466
827
|
if (exists) {
|
|
467
828
|
const columnInfo = await this.knex(tableName).columnInfo();
|
|
@@ -561,6 +922,80 @@ var SqlDriver = class {
|
|
|
561
922
|
}
|
|
562
923
|
return builder;
|
|
563
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
|
+
}
|
|
564
999
|
// ── Filter helpers ──────────────────────────────────────────────────────────
|
|
565
1000
|
applyFilters(builder, filters) {
|
|
566
1001
|
if (!filters) return;
|
|
@@ -799,6 +1234,7 @@ var SqlDriver = class {
|
|
|
799
1234
|
col = table.float(name);
|
|
800
1235
|
break;
|
|
801
1236
|
case "auto_number":
|
|
1237
|
+
case "autonumber":
|
|
802
1238
|
col = table.string(name);
|
|
803
1239
|
break;
|
|
804
1240
|
case "formula":
|
|
@@ -810,6 +1246,16 @@ var SqlDriver = class {
|
|
|
810
1246
|
if (col) {
|
|
811
1247
|
if (field.unique) col.unique();
|
|
812
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
|
+
}
|
|
813
1259
|
}
|
|
814
1260
|
}
|
|
815
1261
|
// ── Database helpers ────────────────────────────────────────────────────────
|
|
@@ -883,10 +1329,28 @@ var SqlDriver = class {
|
|
|
883
1329
|
}
|
|
884
1330
|
// ── SQLite serialisation ────────────────────────────────────────────────────
|
|
885
1331
|
formatInput(object, data) {
|
|
886
|
-
|
|
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;
|
|
887
1348
|
const fields = this.jsonFields[object];
|
|
888
|
-
if (!fields || fields.length === 0) return
|
|
889
|
-
|
|
1349
|
+
if (!fields || fields.length === 0) return copy;
|
|
1350
|
+
if (!copied) {
|
|
1351
|
+
copy = { ...copy };
|
|
1352
|
+
copied = true;
|
|
1353
|
+
}
|
|
890
1354
|
for (const field of fields) {
|
|
891
1355
|
if (copy[field] !== void 0 && typeof copy[field] === "object" && copy[field] !== null) {
|
|
892
1356
|
copy[field] = JSON.stringify(copy[field]);
|