@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 +57 -0
- package/dist/index.d.mts +175 -1
- package/dist/index.d.ts +175 -1
- package/dist/index.js +486 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +486 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
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
|
-
|
|
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;
|