@objectstack/driver-sql 4.0.5 → 4.1.1

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