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