@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/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.supports = {
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 res = await this.getBuilder(object, options).where("id", query).first();
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
- const builder = this.getBuilder(object, options);
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.where("id", id).update(formatted);
176
- const updated = await this.getBuilder(object, options).where("id", id).first();
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 result = await this.getBuilder(object, options).where("id", toUpsert.id).first();
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
- const builder = this.getBuilder(object, options);
196
- const count = await builder.where("id", id).delete();
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
- const builder = this.getBuilder(object, options);
221
- await builder.whereIn("id", ids).delete();
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
- builder.groupBy(query.groupBy);
289
- for (const field of query.groupBy) {
290
- builder.select(field);
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
- builder.select(this.knex.raw(`${rawFunc}(??) as ??`, [agg.field, agg.alias]));
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
- builder.select(this.knex.raw(`${rawFunc}(??)`, [agg.field]));
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 = obj.tableName || obj.name;
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
- if (!this.isSqlite) return data;
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 data;
852
- const copy = { ...data };
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]);