@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.mjs
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
// src/sql-driver.ts
|
|
2
|
+
import { StorageNameMapping } from "@objectstack/spec/system";
|
|
2
3
|
import knex from "knex";
|
|
3
4
|
import { nanoid } from "nanoid";
|
|
4
5
|
var DEFAULT_ID_LENGTH = 16;
|
|
6
|
+
var SEQUENCES_TABLE = "_objectstack_sequences";
|
|
7
|
+
var GLOBAL_TENANT = "__global__";
|
|
5
8
|
var SqlDriver = class {
|
|
6
9
|
constructor(config) {
|
|
7
10
|
// IDataDriver metadata
|
|
8
11
|
this.name = "com.objectstack.driver.sql";
|
|
9
12
|
this.version = "1.0.0";
|
|
10
|
-
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 {
|
|
11
64
|
// Basic CRUD Operations
|
|
12
65
|
create: true,
|
|
13
66
|
read: true,
|
|
@@ -23,6 +76,12 @@ var SqlDriver = class {
|
|
|
23
76
|
// Query Operations
|
|
24
77
|
queryFilters: true,
|
|
25
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,
|
|
26
85
|
querySorting: true,
|
|
27
86
|
queryPagination: true,
|
|
28
87
|
queryWindowFunctions: true,
|
|
@@ -47,11 +106,6 @@ var SqlDriver = class {
|
|
|
47
106
|
preparedStatements: true,
|
|
48
107
|
queryCache: false
|
|
49
108
|
};
|
|
50
|
-
this.jsonFields = {};
|
|
51
|
-
this.booleanFields = {};
|
|
52
|
-
this.tablesWithTimestamps = /* @__PURE__ */ new Set();
|
|
53
|
-
this.config = config;
|
|
54
|
-
this.knex = knex(config);
|
|
55
109
|
}
|
|
56
110
|
/** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
|
|
57
111
|
get isSqlite() {
|
|
@@ -68,6 +122,86 @@ var SqlDriver = class {
|
|
|
68
122
|
const c = this.config.client;
|
|
69
123
|
return c === "mysql" || c === "mysql2";
|
|
70
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
|
+
}
|
|
71
205
|
// ===================================
|
|
72
206
|
// Lifecycle
|
|
73
207
|
// ===================================
|
|
@@ -90,6 +224,7 @@ var SqlDriver = class {
|
|
|
90
224
|
// ===================================
|
|
91
225
|
async find(object, query, options) {
|
|
92
226
|
const builder = this.getBuilder(object, options);
|
|
227
|
+
this.applyTenantScope(builder, object, options);
|
|
93
228
|
if (query.fields) {
|
|
94
229
|
builder.select(query.fields.map((f) => this.mapSortField(f)));
|
|
95
230
|
} else {
|
|
@@ -128,7 +263,9 @@ var SqlDriver = class {
|
|
|
128
263
|
}
|
|
129
264
|
async findOne(object, query, options) {
|
|
130
265
|
if (typeof query === "string" || typeof query === "number") {
|
|
131
|
-
const
|
|
266
|
+
const builder = this.getBuilder(object, options).where("id", query);
|
|
267
|
+
this.applyTenantScope(builder, object, options);
|
|
268
|
+
const res = await builder.first();
|
|
132
269
|
return this.formatOutput(object, res) || null;
|
|
133
270
|
}
|
|
134
271
|
if (query && typeof query === "object") {
|
|
@@ -156,13 +293,153 @@ var SqlDriver = class {
|
|
|
156
293
|
} else if (toInsert.id === void 0) {
|
|
157
294
|
toInsert.id = nanoid(DEFAULT_ID_LENGTH);
|
|
158
295
|
}
|
|
296
|
+
this.auditMissingTenant(object, "create", options);
|
|
297
|
+
this.injectTenantOnInsert(object, toInsert, options);
|
|
298
|
+
await this.fillAutoNumberFields(object, toInsert, options);
|
|
159
299
|
const builder = this.getBuilder(object, options);
|
|
160
300
|
const formatted = this.formatInput(object, toInsert);
|
|
161
301
|
const result = await builder.insert(formatted).returning("*");
|
|
162
302
|
return this.formatOutput(object, result[0]);
|
|
163
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
|
+
}
|
|
164
439
|
async update(object, id, data, options) {
|
|
165
|
-
|
|
440
|
+
this.auditMissingTenant(object, "update", options);
|
|
441
|
+
const builder = this.getBuilder(object, options).where("id", id);
|
|
442
|
+
this.applyTenantScope(builder, object, options);
|
|
166
443
|
const formatted = this.formatInput(object, data);
|
|
167
444
|
if (this.tablesWithTimestamps.has(object)) {
|
|
168
445
|
if (this.isSqlite) {
|
|
@@ -172,8 +449,10 @@ var SqlDriver = class {
|
|
|
172
449
|
formatted.updated_at = this.knex.fn.now();
|
|
173
450
|
}
|
|
174
451
|
}
|
|
175
|
-
await builder.
|
|
176
|
-
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();
|
|
177
456
|
return this.formatOutput(object, updated) || null;
|
|
178
457
|
}
|
|
179
458
|
async upsert(object, data, conflictKeys, options) {
|
|
@@ -184,22 +463,33 @@ var SqlDriver = class {
|
|
|
184
463
|
} else if (toUpsert.id === void 0) {
|
|
185
464
|
toUpsert.id = nanoid(DEFAULT_ID_LENGTH);
|
|
186
465
|
}
|
|
466
|
+
this.auditMissingTenant(object, "upsert", options);
|
|
467
|
+
this.injectTenantOnInsert(object, toUpsert, options);
|
|
468
|
+
await this.fillAutoNumberFields(object, toUpsert, options);
|
|
187
469
|
const formatted = this.formatInput(object, toUpsert);
|
|
188
470
|
const mergeKeys = conflictKeys && conflictKeys.length > 0 ? conflictKeys : ["id"];
|
|
189
471
|
const builder = this.getBuilder(object, options);
|
|
190
472
|
await builder.insert(formatted).onConflict(mergeKeys).merge();
|
|
191
|
-
const
|
|
473
|
+
const readback = this.getBuilder(object, options).where("id", toUpsert.id);
|
|
474
|
+
this.applyTenantScope(readback, object, options);
|
|
475
|
+
const result = await readback.first();
|
|
192
476
|
return this.formatOutput(object, result) || toUpsert;
|
|
193
477
|
}
|
|
194
478
|
async delete(object, id, options) {
|
|
195
|
-
|
|
196
|
-
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();
|
|
197
483
|
return count > 0;
|
|
198
484
|
}
|
|
199
485
|
// ===================================
|
|
200
486
|
// Bulk & Batch Operations
|
|
201
487
|
// ===================================
|
|
202
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
|
+
}
|
|
203
493
|
const builder = this.getBuilder(object, options);
|
|
204
494
|
return await builder.insert(data).returning("*");
|
|
205
495
|
}
|
|
@@ -217,23 +507,30 @@ var SqlDriver = class {
|
|
|
217
507
|
return results;
|
|
218
508
|
}
|
|
219
509
|
async bulkDelete(object, ids, options) {
|
|
220
|
-
|
|
221
|
-
|
|
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();
|
|
222
514
|
}
|
|
223
515
|
async updateMany(object, query, data, options) {
|
|
516
|
+
this.auditMissingTenant(object, "updateMany", options);
|
|
224
517
|
const builder = this.getBuilder(object, options);
|
|
518
|
+
this.applyTenantScope(builder, object, options);
|
|
225
519
|
if (query.where) this.applyFilters(builder, query.where);
|
|
226
520
|
const count = await builder.update(data);
|
|
227
521
|
return count || 0;
|
|
228
522
|
}
|
|
229
523
|
async deleteMany(object, query, options) {
|
|
524
|
+
this.auditMissingTenant(object, "deleteMany", options);
|
|
230
525
|
const builder = this.getBuilder(object, options);
|
|
526
|
+
this.applyTenantScope(builder, object, options);
|
|
231
527
|
if (query.where) this.applyFilters(builder, query.where);
|
|
232
528
|
const count = await builder.delete();
|
|
233
529
|
return count || 0;
|
|
234
530
|
}
|
|
235
531
|
async count(object, query, options) {
|
|
236
532
|
const builder = this.getBuilder(object, options);
|
|
533
|
+
this.applyTenantScope(builder, object, options);
|
|
237
534
|
if (query?.where) {
|
|
238
535
|
this.applyFilters(builder, query.where);
|
|
239
536
|
}
|
|
@@ -247,6 +544,22 @@ var SqlDriver = class {
|
|
|
247
544
|
// ===================================
|
|
248
545
|
// Raw Execution
|
|
249
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
|
+
*/
|
|
250
563
|
async execute(command, params, options) {
|
|
251
564
|
if (typeof command !== "string") {
|
|
252
565
|
return command;
|
|
@@ -281,13 +594,30 @@ var SqlDriver = class {
|
|
|
281
594
|
// ===================================
|
|
282
595
|
async aggregate(object, query, options) {
|
|
283
596
|
const builder = this.getBuilder(object, options);
|
|
597
|
+
this.applyTenantScope(builder, object, options);
|
|
284
598
|
if (query.where) {
|
|
285
599
|
this.applyFilters(builder, query.where);
|
|
286
600
|
}
|
|
287
601
|
if (query.groupBy) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|
|
291
621
|
}
|
|
292
622
|
}
|
|
293
623
|
const aggregates = query.aggregations || query.aggregate;
|
|
@@ -295,10 +625,19 @@ var SqlDriver = class {
|
|
|
295
625
|
for (const agg of aggregates) {
|
|
296
626
|
const funcName = agg.function || agg.func;
|
|
297
627
|
const rawFunc = this.mapAggregateFunc(funcName);
|
|
628
|
+
const fieldExpr = agg.field ?? "*";
|
|
298
629
|
if (agg.alias) {
|
|
299
|
-
|
|
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
|
+
}
|
|
300
635
|
} else {
|
|
301
|
-
|
|
636
|
+
if (fieldExpr === "*") {
|
|
637
|
+
builder.select(this.knex.raw(`${rawFunc}(*)`));
|
|
638
|
+
} else {
|
|
639
|
+
builder.select(this.knex.raw(`${rawFunc}(??)`, [fieldExpr]));
|
|
640
|
+
}
|
|
302
641
|
}
|
|
303
642
|
}
|
|
304
643
|
}
|
|
@@ -409,9 +748,22 @@ var SqlDriver = class {
|
|
|
409
748
|
async initObjects(objects) {
|
|
410
749
|
await this.ensureDatabaseExists();
|
|
411
750
|
for (const obj of objects) {
|
|
412
|
-
const tableName =
|
|
751
|
+
const tableName = StorageNameMapping.resolveTableName(obj);
|
|
413
752
|
const jsonCols = [];
|
|
414
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
|
+
}
|
|
415
767
|
if (obj.fields) {
|
|
416
768
|
for (const [name, field] of Object.entries(obj.fields)) {
|
|
417
769
|
const type = field.type || "string";
|
|
@@ -421,10 +773,19 @@ var SqlDriver = class {
|
|
|
421
773
|
if (type === "boolean") {
|
|
422
774
|
booleanCols.push(name);
|
|
423
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
|
+
}
|
|
424
783
|
}
|
|
425
784
|
}
|
|
426
785
|
this.jsonFields[tableName] = jsonCols;
|
|
427
786
|
this.booleanFields[tableName] = booleanCols;
|
|
787
|
+
this.autoNumberFields[tableName] = autoNumberCols;
|
|
788
|
+
this.tenantFieldByTable[tableName] = tenantField;
|
|
428
789
|
let exists = await this.knex.schema.hasTable(tableName);
|
|
429
790
|
if (exists) {
|
|
430
791
|
const columnInfo = await this.knex(tableName).columnInfo();
|
|
@@ -524,6 +885,80 @@ var SqlDriver = class {
|
|
|
524
885
|
}
|
|
525
886
|
return builder;
|
|
526
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
|
+
}
|
|
527
962
|
// ── Filter helpers ──────────────────────────────────────────────────────────
|
|
528
963
|
applyFilters(builder, filters) {
|
|
529
964
|
if (!filters) return;
|
|
@@ -762,6 +1197,7 @@ var SqlDriver = class {
|
|
|
762
1197
|
col = table.float(name);
|
|
763
1198
|
break;
|
|
764
1199
|
case "auto_number":
|
|
1200
|
+
case "autonumber":
|
|
765
1201
|
col = table.string(name);
|
|
766
1202
|
break;
|
|
767
1203
|
case "formula":
|
|
@@ -773,6 +1209,16 @@ var SqlDriver = class {
|
|
|
773
1209
|
if (col) {
|
|
774
1210
|
if (field.unique) col.unique();
|
|
775
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
|
+
}
|
|
776
1222
|
}
|
|
777
1223
|
}
|
|
778
1224
|
// ── Database helpers ────────────────────────────────────────────────────────
|
|
@@ -846,10 +1292,28 @@ var SqlDriver = class {
|
|
|
846
1292
|
}
|
|
847
1293
|
// ── SQLite serialisation ────────────────────────────────────────────────────
|
|
848
1294
|
formatInput(object, data) {
|
|
849
|
-
|
|
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;
|
|
850
1311
|
const fields = this.jsonFields[object];
|
|
851
|
-
if (!fields || fields.length === 0) return
|
|
852
|
-
|
|
1312
|
+
if (!fields || fields.length === 0) return copy;
|
|
1313
|
+
if (!copied) {
|
|
1314
|
+
copy = { ...copy };
|
|
1315
|
+
copied = true;
|
|
1316
|
+
}
|
|
853
1317
|
for (const field of fields) {
|
|
854
1318
|
if (copy[field] !== void 0 && typeof copy[field] === "object" && copy[field] !== null) {
|
|
855
1319
|
copy[field] = JSON.stringify(copy[field]);
|