@objectstack/driver-sql 4.0.5 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -276,6 +276,63 @@ const results = await driver.raw(
276
276
  );
277
277
  ```
278
278
 
279
+ > ⚠️ **Raw SQL bypasses driver-level tenant isolation.** The `WHERE
280
+ > organization_id = ?` predicate that `find` / `update` / `delete`
281
+ > auto-apply is **not** added to `driver.raw()` or `engine.execute()`
282
+ > output. Always include the tenant predicate yourself when running raw
283
+ > queries against tenant-scoped tables.
284
+
285
+ ## Tenant Isolation (Row-Level)
286
+
287
+ When an object declares a tenant field (either explicitly via
288
+ `tenancy.tenantField`, or implicitly by having an `organization_id`
289
+ field), the driver auto-scopes every CRUD call by the caller's
290
+ `options.tenantId`:
291
+
292
+ | Operation | Scope behavior |
293
+ |---|---|
294
+ | `find`, `findOne`, `count`, `aggregate` | `WHERE <tenantField> = :tenantId` injected |
295
+ | `update`, `delete`, `updateMany`, `deleteMany`, `bulkDelete` | Same `WHERE` injected — cross-tenant writes silently no-op |
296
+ | `create`, `upsert`, `bulkCreate` | `<tenantField>` auto-injected on each row if absent |
297
+
298
+ The engine (`@objectstack/objectql`) threads `ExecutionContext.tenantId`
299
+ into options for you; manual `driver.find(...)` calls can pass
300
+ `{ tenantId: '...' }` directly.
301
+
302
+ ### Declaring the tenant field
303
+
304
+ ```ts
305
+ // Custom tenant column (default is 'organization_id')
306
+ {
307
+ name: 'workspace_item',
308
+ tenancy: { enabled: true, strategy: 'shared', tenantField: 'workspace_id' },
309
+ fields: {
310
+ workspace_id: { type: 'string' },
311
+ /* ... */
312
+ },
313
+ }
314
+ ```
315
+
316
+ ### Bypasses (intentional, documented)
317
+
318
+ | Path | Tenant-scoped? | Why |
319
+ |---|---|---|
320
+ | Callers that omit `options.tenantId` | No | Seed scripts, boot-time installers, admin tooling |
321
+ | `ExecutionContext.isSystem === true` | No (auto-`bypassTenantAudit`) | Kernel-internal mirrors, scheduled hooks |
322
+ | Explicit `organization_id` on insert row | Wins | Admin tooling can target a specific tenant |
323
+ | `driver.raw()` / `engine.execute(sql)` | No | Raw SQL is on you |
324
+ | `driver.bulkUpdate` | Yes (it loops `update`) | Same scope as `update` |
325
+
326
+ ### Audit warning
327
+
328
+ The driver logs **one warning per `{object}:{op}`** when a write hits a
329
+ tenant-scoped object without `options.tenantId`. Genuine system writes
330
+ (`ExecutionContext.isSystem === true`) auto-silence; everything else
331
+ surfaces as `[tenant-audit] ...` so missing-context bugs are visible.
332
+
333
+ Override per call: `options.bypassTenantAudit = true`.
334
+ Override globally: `OS_TENANT_AUDIT=0`.
335
+
279
336
  ## Database-Specific Features
280
337
 
281
338
  ### PostgreSQL Features
package/dist/index.d.mts CHANGED
@@ -47,7 +47,7 @@ type SqlDriverConfig = Knex.Config;
47
47
  declare class SqlDriver implements IDataDriver {
48
48
  readonly name: string;
49
49
  readonly version: string;
50
- readonly supports: {
50
+ get supports(): {
51
51
  create: boolean;
52
52
  read: boolean;
53
53
  update: boolean;
@@ -59,6 +59,12 @@ declare class SqlDriver implements IDataDriver {
59
59
  savepoints: boolean;
60
60
  queryFilters: boolean;
61
61
  queryAggregations: boolean;
62
+ /**
63
+ * Per-granularity native date bucket support. Granularities marked
64
+ * `false` (or absent) fall back to in-memory `bucketDateValue()` via
65
+ * `engine.findData` — see `buildDateBucketExpr()` for the SQL emitted.
66
+ */
67
+ queryDateGranularity: Record<string, boolean>;
62
68
  querySorting: boolean;
63
69
  queryPagination: boolean;
64
70
  queryWindowFunctions: boolean;
@@ -85,12 +91,88 @@ declare class SqlDriver implements IDataDriver {
85
91
  protected jsonFields: Record<string, string[]>;
86
92
  protected booleanFields: Record<string, string[]>;
87
93
  protected tablesWithTimestamps: Set<string>;
94
+ /**
95
+ * Autonumber field configs per table, captured during initObjects.
96
+ *
97
+ * Each entry records:
98
+ * - `prefix` + `padWidth`: how to render the next value (`CTR-0007`)
99
+ * - `tenantField`: the column to scope the sequence by (defaults to
100
+ * `organization_id` if the object has that field, otherwise null →
101
+ * sequence is shared globally for that field)
102
+ *
103
+ * Numbering is backed by the `_objectstack_sequences` row keyed by
104
+ * `(object, tenant_id, field)`, not by scanning the data table on each
105
+ * insert. The sequence row is bootstrapped from the existing MAX on
106
+ * first use so legacy data is respected.
107
+ */
108
+ protected autoNumberFields: Record<string, Array<{
109
+ name: string;
110
+ format: string;
111
+ prefix: string;
112
+ padWidth: number;
113
+ tenantField: string | null;
114
+ }>>;
115
+ /** Whether the sequences table has been ensured this process. */
116
+ protected sequencesTableReady: boolean;
117
+ /** In-flight ensure promise; deduplicates concurrent first calls. */
118
+ protected sequencesTableEnsurePromise: Promise<void> | null;
119
+ /**
120
+ * Per-table tenant-isolation column. Populated during `initObjects` by
121
+ * detecting an `organization_id` field. When set and the caller passes
122
+ * `DriverOptions.tenantId`, the driver automatically:
123
+ *
124
+ * - scopes reads/updates/deletes/aggregates to that tenant
125
+ * - injects `organization_id` on inserts that omit it
126
+ *
127
+ * If `tenantId` is absent (admin / seed / system path) no scope is
128
+ * applied — preserves backward compatibility for tools that legitimately
129
+ * need cross-tenant access. Tenant enforcement is therefore opt-in by
130
+ * the caller, not by the driver.
131
+ */
132
+ protected tenantFieldByTable: Record<string, string | null>;
133
+ /** Throttle table for missing-tenantId warnings ({object}:{op}). */
134
+ protected tenantAuditWarned: Set<string>;
135
+ /**
136
+ * Optional logger sink for security-audit warnings. Tests inject a spy;
137
+ * production callers wire in their preferred logger. Defaults to
138
+ * `console.warn` so warnings surface even without setup.
139
+ */
140
+ protected logger: {
141
+ warn: (msg: string, meta?: any) => void;
142
+ };
88
143
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
89
144
  protected get isSqlite(): boolean;
90
145
  /** Whether the underlying database is PostgreSQL. */
91
146
  protected get isPostgres(): boolean;
92
147
  /** Whether the underlying database is MySQL. */
93
148
  protected get isMysql(): boolean;
149
+ /**
150
+ * Per-granularity native SQL bucket support, computed from dialect.
151
+ *
152
+ * Must match `bucketDateValue()` in @objectstack/objectql exactly:
153
+ * year → 'YYYY'
154
+ * month → 'YYYY-MM'
155
+ * day → 'YYYY-MM-DD'
156
+ * quarter → 'YYYY-Q[1-4]'
157
+ * week → 'YYYY-W[01-53]' (ISO-8601)
158
+ *
159
+ * Granularities not listed (or set to false) fall back to in-memory bucketing
160
+ * via engine.findData → applyInMemoryAggregation.
161
+ */
162
+ protected get dateGranularityCapabilities(): Record<string, boolean>;
163
+ /**
164
+ * Build SQL fragment + bindings for a date bucket expression.
165
+ * Returns `null` when the current dialect does not support the requested
166
+ * granularity — callers must fall back to in-memory bucketing.
167
+ *
168
+ * Exposed as `{sql, bindings}` (not `Knex.Raw`) so callers can both
169
+ * `groupByRaw()` and embed the same expression inside a `select() as alias`
170
+ * with correctly forwarded identifier bindings.
171
+ */
172
+ protected buildDateBucketExpr(field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year'): {
173
+ sql: string;
174
+ bindings: any[];
175
+ } | null;
94
176
  constructor(config: SqlDriverConfig);
95
177
  connect(): Promise<void>;
96
178
  checkHealth(): Promise<boolean>;
@@ -104,6 +186,40 @@ declare class SqlDriver implements IDataDriver {
104
186
  */
105
187
  findStream(object: string, query: QueryAST, options?: DriverOptions): AsyncGenerator<Record<string, any>>;
106
188
  create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<any>;
189
+ /**
190
+ * Ensure the sequence-counter table exists. Idempotent and cheap after
191
+ * the first call (cached via `sequencesTableReady`).
192
+ */
193
+ protected ensureSequencesTable(): Promise<void>;
194
+ /**
195
+ * Bootstrap helper: scan the data table for the highest numeric suffix
196
+ * matching `prefix` (optionally scoped to a tenant). Used the first time
197
+ * a sequence row is created so legacy/seeded data continues monotonically.
198
+ */
199
+ protected scanMaxNumericTail(queryRunner: Knex | Knex.Transaction, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null): Promise<number>;
200
+ /**
201
+ * Atomically reserve and return the next sequence value for
202
+ * `(object, tenantId, field)`. Bootstraps from the data-table MAX on
203
+ * first call so existing seeded records continue monotonically.
204
+ *
205
+ * Concurrency:
206
+ * - SQLite: a write transaction (`BEGIN IMMEDIATE` via knex) serializes
207
+ * all writers; safe in-process. Cross-process SQLite is out of scope.
208
+ * - Postgres/MySQL: `SELECT … FOR UPDATE` row lock ensures only one
209
+ * transaction reads-modifies-writes at a time. A PK-violation race on
210
+ * first insert is retried as an UPDATE.
211
+ *
212
+ * Gaps are tolerated by design — a rolled-back insert "burns" a number,
213
+ * matching standard sequence semantics.
214
+ */
215
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
216
+ /**
217
+ * For each `auto_number` field on the object that the caller did not
218
+ * provide a value for, reserve the next sequence value scoped to the
219
+ * record's tenant (or globally if the object has no tenant field) and
220
+ * render `prefix + zero-padded(value)`.
221
+ */
222
+ protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
107
223
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
108
224
  upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, any>>;
109
225
  delete(object: string, id: string | number, options?: DriverOptions): Promise<boolean>;
@@ -121,6 +237,22 @@ declare class SqlDriver implements IDataDriver {
121
237
  updateMany(object: string, query: QueryAST, data: any, options?: DriverOptions): Promise<number>;
122
238
  deleteMany(object: string, query: QueryAST, options?: DriverOptions): Promise<number>;
123
239
  count(object: string, query?: QueryAST, options?: DriverOptions): Promise<number>;
240
+ /**
241
+ * Run a raw SQL string or knex builder through the underlying knex
242
+ * connection.
243
+ *
244
+ * ⚠️ **Tenant isolation bypass.** Unlike `find`/`update`/`delete` etc.,
245
+ * raw `execute()` does NOT inject the `organization_id` predicate. The
246
+ * caller is responsible for either:
247
+ * - inlining the tenant filter into the SQL (`WHERE organization_id = ?`),
248
+ * - or restricting `execute()` to genuinely global queries
249
+ * (schema introspection, sys_* tables that opt out of tenancy).
250
+ *
251
+ * Prefer the typed CRUD APIs whenever the operation can be expressed
252
+ * through them — they handle tenancy, soft-delete, and audit warnings
253
+ * automatically. See `README.md > Tenant Isolation` for the full bypass
254
+ * matrix.
255
+ */
124
256
  execute(command: any, params?: any[], options?: DriverOptions): Promise<any>;
125
257
  beginTransaction(): Promise<Knex.Transaction>;
126
258
  /** IDataDriver standard */
@@ -158,6 +290,48 @@ declare class SqlDriver implements IDataDriver {
158
290
  _intersectProps: {};
159
291
  _unionProps: never;
160
292
  }[]>;
293
+ /**
294
+ * Resolve the tenant column for the given object, if any.
295
+ *
296
+ * Lookup falls back to both the storage-mapped table name and the raw
297
+ * object name so callers that pass either form get the same answer.
298
+ * Returns `null` when the object has no tenant-isolation field.
299
+ */
300
+ protected resolveTenantField(object: string): string | null;
301
+ /**
302
+ * Apply a `WHERE tenant_field = ?` clause to the given query builder
303
+ * when:
304
+ * 1. `options.tenantId` is provided by the caller, AND
305
+ * 2. the object actually has a tenant-isolation field
306
+ * (`organization_id` by convention).
307
+ *
308
+ * Without a tenantId the call is treated as an unscoped/admin path —
309
+ * keeps legacy callers, seed scripts, and cross-org tooling working.
310
+ * This is the single chokepoint for read-side tenant isolation in the
311
+ * SQL driver; every CRUD method routes through it.
312
+ */
313
+ protected applyTenantScope(builder: Knex.QueryBuilder, object: string, options?: DriverOptions): Knex.QueryBuilder;
314
+ /**
315
+ * Auto-inject the tenant column on insert rows when:
316
+ * 1. `options.tenantId` is provided, AND
317
+ * 2. the object has a tenant-isolation field, AND
318
+ * 3. the row does not already set that field.
319
+ *
320
+ * Explicit values are never overwritten — admins writing to a specific
321
+ * tenant via raw row data keep that authority.
322
+ */
323
+ protected injectTenantOnInsert(object: string, row: Record<string, any>, options?: DriverOptions): void;
324
+ /**
325
+ * Surface writes that target a tenant-scoped object but don't carry a
326
+ * `tenantId`. These are almost always system / seed / admin paths that
327
+ * forgot to thread the active session context — easy to miss in code
328
+ * review and impossible to find after a breach.
329
+ *
330
+ * Throttled to one warning per `${object}:${op}` so background workers
331
+ * don't spam the log. Set `options.bypassTenantAudit = true` (or env
332
+ * `OS_TENANT_AUDIT=0`) to silence intentionally.
333
+ */
334
+ protected auditMissingTenant(object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions): void;
161
335
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
162
336
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or'): void;
163
337
  protected mapSortField(field: string): string;
package/dist/index.d.ts CHANGED
@@ -47,7 +47,7 @@ type SqlDriverConfig = Knex.Config;
47
47
  declare class SqlDriver implements IDataDriver {
48
48
  readonly name: string;
49
49
  readonly version: string;
50
- readonly supports: {
50
+ get supports(): {
51
51
  create: boolean;
52
52
  read: boolean;
53
53
  update: boolean;
@@ -59,6 +59,12 @@ declare class SqlDriver implements IDataDriver {
59
59
  savepoints: boolean;
60
60
  queryFilters: boolean;
61
61
  queryAggregations: boolean;
62
+ /**
63
+ * Per-granularity native date bucket support. Granularities marked
64
+ * `false` (or absent) fall back to in-memory `bucketDateValue()` via
65
+ * `engine.findData` — see `buildDateBucketExpr()` for the SQL emitted.
66
+ */
67
+ queryDateGranularity: Record<string, boolean>;
62
68
  querySorting: boolean;
63
69
  queryPagination: boolean;
64
70
  queryWindowFunctions: boolean;
@@ -85,12 +91,88 @@ declare class SqlDriver implements IDataDriver {
85
91
  protected jsonFields: Record<string, string[]>;
86
92
  protected booleanFields: Record<string, string[]>;
87
93
  protected tablesWithTimestamps: Set<string>;
94
+ /**
95
+ * Autonumber field configs per table, captured during initObjects.
96
+ *
97
+ * Each entry records:
98
+ * - `prefix` + `padWidth`: how to render the next value (`CTR-0007`)
99
+ * - `tenantField`: the column to scope the sequence by (defaults to
100
+ * `organization_id` if the object has that field, otherwise null →
101
+ * sequence is shared globally for that field)
102
+ *
103
+ * Numbering is backed by the `_objectstack_sequences` row keyed by
104
+ * `(object, tenant_id, field)`, not by scanning the data table on each
105
+ * insert. The sequence row is bootstrapped from the existing MAX on
106
+ * first use so legacy data is respected.
107
+ */
108
+ protected autoNumberFields: Record<string, Array<{
109
+ name: string;
110
+ format: string;
111
+ prefix: string;
112
+ padWidth: number;
113
+ tenantField: string | null;
114
+ }>>;
115
+ /** Whether the sequences table has been ensured this process. */
116
+ protected sequencesTableReady: boolean;
117
+ /** In-flight ensure promise; deduplicates concurrent first calls. */
118
+ protected sequencesTableEnsurePromise: Promise<void> | null;
119
+ /**
120
+ * Per-table tenant-isolation column. Populated during `initObjects` by
121
+ * detecting an `organization_id` field. When set and the caller passes
122
+ * `DriverOptions.tenantId`, the driver automatically:
123
+ *
124
+ * - scopes reads/updates/deletes/aggregates to that tenant
125
+ * - injects `organization_id` on inserts that omit it
126
+ *
127
+ * If `tenantId` is absent (admin / seed / system path) no scope is
128
+ * applied — preserves backward compatibility for tools that legitimately
129
+ * need cross-tenant access. Tenant enforcement is therefore opt-in by
130
+ * the caller, not by the driver.
131
+ */
132
+ protected tenantFieldByTable: Record<string, string | null>;
133
+ /** Throttle table for missing-tenantId warnings ({object}:{op}). */
134
+ protected tenantAuditWarned: Set<string>;
135
+ /**
136
+ * Optional logger sink for security-audit warnings. Tests inject a spy;
137
+ * production callers wire in their preferred logger. Defaults to
138
+ * `console.warn` so warnings surface even without setup.
139
+ */
140
+ protected logger: {
141
+ warn: (msg: string, meta?: any) => void;
142
+ };
88
143
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
89
144
  protected get isSqlite(): boolean;
90
145
  /** Whether the underlying database is PostgreSQL. */
91
146
  protected get isPostgres(): boolean;
92
147
  /** Whether the underlying database is MySQL. */
93
148
  protected get isMysql(): boolean;
149
+ /**
150
+ * Per-granularity native SQL bucket support, computed from dialect.
151
+ *
152
+ * Must match `bucketDateValue()` in @objectstack/objectql exactly:
153
+ * year → 'YYYY'
154
+ * month → 'YYYY-MM'
155
+ * day → 'YYYY-MM-DD'
156
+ * quarter → 'YYYY-Q[1-4]'
157
+ * week → 'YYYY-W[01-53]' (ISO-8601)
158
+ *
159
+ * Granularities not listed (or set to false) fall back to in-memory bucketing
160
+ * via engine.findData → applyInMemoryAggregation.
161
+ */
162
+ protected get dateGranularityCapabilities(): Record<string, boolean>;
163
+ /**
164
+ * Build SQL fragment + bindings for a date bucket expression.
165
+ * Returns `null` when the current dialect does not support the requested
166
+ * granularity — callers must fall back to in-memory bucketing.
167
+ *
168
+ * Exposed as `{sql, bindings}` (not `Knex.Raw`) so callers can both
169
+ * `groupByRaw()` and embed the same expression inside a `select() as alias`
170
+ * with correctly forwarded identifier bindings.
171
+ */
172
+ protected buildDateBucketExpr(field: string, granularity: 'day' | 'week' | 'month' | 'quarter' | 'year'): {
173
+ sql: string;
174
+ bindings: any[];
175
+ } | null;
94
176
  constructor(config: SqlDriverConfig);
95
177
  connect(): Promise<void>;
96
178
  checkHealth(): Promise<boolean>;
@@ -104,6 +186,40 @@ declare class SqlDriver implements IDataDriver {
104
186
  */
105
187
  findStream(object: string, query: QueryAST, options?: DriverOptions): AsyncGenerator<Record<string, any>>;
106
188
  create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<any>;
189
+ /**
190
+ * Ensure the sequence-counter table exists. Idempotent and cheap after
191
+ * the first call (cached via `sequencesTableReady`).
192
+ */
193
+ protected ensureSequencesTable(): Promise<void>;
194
+ /**
195
+ * Bootstrap helper: scan the data table for the highest numeric suffix
196
+ * matching `prefix` (optionally scoped to a tenant). Used the first time
197
+ * a sequence row is created so legacy/seeded data continues monotonically.
198
+ */
199
+ protected scanMaxNumericTail(queryRunner: Knex | Knex.Transaction, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null): Promise<number>;
200
+ /**
201
+ * Atomically reserve and return the next sequence value for
202
+ * `(object, tenantId, field)`. Bootstraps from the data-table MAX on
203
+ * first call so existing seeded records continue monotonically.
204
+ *
205
+ * Concurrency:
206
+ * - SQLite: a write transaction (`BEGIN IMMEDIATE` via knex) serializes
207
+ * all writers; safe in-process. Cross-process SQLite is out of scope.
208
+ * - Postgres/MySQL: `SELECT … FOR UPDATE` row lock ensures only one
209
+ * transaction reads-modifies-writes at a time. A PK-violation race on
210
+ * first insert is retried as an UPDATE.
211
+ *
212
+ * Gaps are tolerated by design — a rolled-back insert "burns" a number,
213
+ * matching standard sequence semantics.
214
+ */
215
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
216
+ /**
217
+ * For each `auto_number` field on the object that the caller did not
218
+ * provide a value for, reserve the next sequence value scoped to the
219
+ * record's tenant (or globally if the object has no tenant field) and
220
+ * render `prefix + zero-padded(value)`.
221
+ */
222
+ protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
107
223
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
108
224
  upsert(object: string, data: Record<string, any>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, any>>;
109
225
  delete(object: string, id: string | number, options?: DriverOptions): Promise<boolean>;
@@ -121,6 +237,22 @@ declare class SqlDriver implements IDataDriver {
121
237
  updateMany(object: string, query: QueryAST, data: any, options?: DriverOptions): Promise<number>;
122
238
  deleteMany(object: string, query: QueryAST, options?: DriverOptions): Promise<number>;
123
239
  count(object: string, query?: QueryAST, options?: DriverOptions): Promise<number>;
240
+ /**
241
+ * Run a raw SQL string or knex builder through the underlying knex
242
+ * connection.
243
+ *
244
+ * ⚠️ **Tenant isolation bypass.** Unlike `find`/`update`/`delete` etc.,
245
+ * raw `execute()` does NOT inject the `organization_id` predicate. The
246
+ * caller is responsible for either:
247
+ * - inlining the tenant filter into the SQL (`WHERE organization_id = ?`),
248
+ * - or restricting `execute()` to genuinely global queries
249
+ * (schema introspection, sys_* tables that opt out of tenancy).
250
+ *
251
+ * Prefer the typed CRUD APIs whenever the operation can be expressed
252
+ * through them — they handle tenancy, soft-delete, and audit warnings
253
+ * automatically. See `README.md > Tenant Isolation` for the full bypass
254
+ * matrix.
255
+ */
124
256
  execute(command: any, params?: any[], options?: DriverOptions): Promise<any>;
125
257
  beginTransaction(): Promise<Knex.Transaction>;
126
258
  /** IDataDriver standard */
@@ -158,6 +290,48 @@ declare class SqlDriver implements IDataDriver {
158
290
  _intersectProps: {};
159
291
  _unionProps: never;
160
292
  }[]>;
293
+ /**
294
+ * Resolve the tenant column for the given object, if any.
295
+ *
296
+ * Lookup falls back to both the storage-mapped table name and the raw
297
+ * object name so callers that pass either form get the same answer.
298
+ * Returns `null` when the object has no tenant-isolation field.
299
+ */
300
+ protected resolveTenantField(object: string): string | null;
301
+ /**
302
+ * Apply a `WHERE tenant_field = ?` clause to the given query builder
303
+ * when:
304
+ * 1. `options.tenantId` is provided by the caller, AND
305
+ * 2. the object actually has a tenant-isolation field
306
+ * (`organization_id` by convention).
307
+ *
308
+ * Without a tenantId the call is treated as an unscoped/admin path —
309
+ * keeps legacy callers, seed scripts, and cross-org tooling working.
310
+ * This is the single chokepoint for read-side tenant isolation in the
311
+ * SQL driver; every CRUD method routes through it.
312
+ */
313
+ protected applyTenantScope(builder: Knex.QueryBuilder, object: string, options?: DriverOptions): Knex.QueryBuilder;
314
+ /**
315
+ * Auto-inject the tenant column on insert rows when:
316
+ * 1. `options.tenantId` is provided, AND
317
+ * 2. the object has a tenant-isolation field, AND
318
+ * 3. the row does not already set that field.
319
+ *
320
+ * Explicit values are never overwritten — admins writing to a specific
321
+ * tenant via raw row data keep that authority.
322
+ */
323
+ protected injectTenantOnInsert(object: string, row: Record<string, any>, options?: DriverOptions): void;
324
+ /**
325
+ * Surface writes that target a tenant-scoped object but don't carry a
326
+ * `tenantId`. These are almost always system / seed / admin paths that
327
+ * forgot to thread the active session context — easy to miss in code
328
+ * review and impossible to find after a breach.
329
+ *
330
+ * Throttled to one warning per `${object}:${op}` so background workers
331
+ * don't spam the log. Set `options.bypassTenantAudit = true` (or env
332
+ * `OS_TENANT_AUDIT=0`) to silence intentionally.
333
+ */
334
+ protected auditMissingTenant(object: string, op: 'create' | 'update' | 'delete' | 'bulkCreate' | 'bulkDelete' | 'updateMany' | 'deleteMany' | 'upsert', options?: DriverOptions): void;
161
335
  protected applyFilters(builder: Knex.QueryBuilder, filters: any): void;
162
336
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or'): void;
163
337
  protected mapSortField(field: string): string;