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