@objectstack/driver-sql 9.10.0 → 10.0.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.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -101,6 +101,20 @@ declare class SqlDriver implements IDataDriver {
101
101
  protected numericFields: Record<string, string[]>;
102
102
  protected dateFields: Record<string, Set<string>>;
103
103
  protected datetimeFields: Record<string, Set<string>>;
104
+ /**
105
+ * Federation read path (ADR-0015). For external objects whose physical
106
+ * remote table differs from the object name, these map between the two so
107
+ * {@link getBuilder} targets the remote table while the coercion maps above
108
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
109
+ * managed objects, so the managed query path is unchanged.
110
+ */
111
+ protected physicalTableByObject: Record<string, string>;
112
+ protected physicalSchemaByObject: Record<string, string>;
113
+ protected objectByPhysicalTable: Record<string, string>;
114
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
115
+ protected fieldColumnByObject: Record<string, Record<string, string>>;
116
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
117
+ protected columnFieldByObject: Record<string, Record<string, string>>;
104
118
  protected tablesWithTimestamps: Set<string>;
105
119
  /**
106
120
  * Autonumber field configs per table, captured during initObjects.
@@ -119,12 +133,19 @@ declare class SqlDriver implements IDataDriver {
119
133
  protected autoNumberFields: Record<string, Array<{
120
134
  name: string;
121
135
  format: string;
122
- prefix: string;
123
- padWidth: number;
136
+ tokens: AutonumberToken[];
124
137
  tenantField: string | null;
125
138
  }>>;
126
139
  /** Whether the sequences table has been ensured this process. */
127
140
  protected sequencesTableReady: boolean;
141
+ /**
142
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
143
+ * Set on a fresh create or a successful in-place migration. If a legacy table
144
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
145
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
146
+ * per-scope write raises an actionable error rather than corrupting counters.
147
+ */
148
+ protected sequencesHasKeyHash: boolean;
128
149
  /** In-flight ensure promise; deduplicates concurrent first calls. */
129
150
  protected sequencesTableEnsurePromise: Promise<void> | null;
130
151
  /**
@@ -214,8 +235,34 @@ declare class SqlDriver implements IDataDriver {
214
235
  /**
215
236
  * Ensure the sequence-counter table exists. Idempotent and cheap after
216
237
  * the first call (cached via `sequencesTableReady`).
238
+ *
239
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
240
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
241
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
242
+ * single 64-char hashed primary key (rather than the four raw columns, which
243
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
244
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
245
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
246
+ * keep their single global counter (backward compatible).
217
247
  */
218
248
  protected ensureSequencesTable(): Promise<void>;
249
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
250
+ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string;
251
+ /** Create the current `key_hash`-keyed sequences table shape. */
252
+ protected createSequencesTable(table: string): Promise<void>;
253
+ /**
254
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
255
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
256
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
257
+ * every legacy row is read, its `key_hash` computed in app code (no portable
258
+ * SQL hash exists), and re-inserted into a freshly built table that then
259
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
260
+ *
261
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
262
+ * sequences keep working via the legacy key and per-scope writes error
263
+ * actionably (see getNextSequenceValue), rather than corrupting data.
264
+ */
265
+ protected ensureSequencesKeyHashShape(): Promise<void>;
219
266
  /**
220
267
  * Bootstrap helper: scan the data table for the highest numeric suffix
221
268
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -237,12 +284,14 @@ declare class SqlDriver implements IDataDriver {
237
284
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
238
285
  * matching standard sequence semantics.
239
286
  */
240
- protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
287
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, scope?: string): Promise<number>;
241
288
  /**
242
- * For each `auto_number` field on the object that the caller did not
243
- * provide a value for, reserve the next sequence value scoped to the
244
- * record's tenant (or globally if the object has no tenant field) and
245
- * render `prefix + zero-padded(value)`.
289
+ * For each `auto_number` field the caller left empty, render the format and
290
+ * reserve the next counter value. The counter is scoped to the rendered
291
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
292
+ * plus `{field}` interpolation from the row), so it resets per period/group;
293
+ * the full rendered prefix bootstraps the counter from existing data, and the
294
+ * tenant scopes it for isolation.
246
295
  */
247
296
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
248
297
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
@@ -299,6 +348,30 @@ declare class SqlDriver implements IDataDriver {
299
348
  /**
300
349
  * Batch-initialise tables from an array of object definitions.
301
350
  */
351
+ /**
352
+ * DDL-free metadata registration for a federated (external) object — the
353
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
354
+ *
355
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
356
+ * any non-`managed` driver, which left external objects with NO read-coercion
357
+ * metadata and the query path resolving to a table named after the object
358
+ * instead of its remote table. This populates the same coercion maps (keyed
359
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
360
+ * records the physical remote table (`external.remoteName`, optionally
361
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
362
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
363
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
364
+ */
365
+ registerExternalObject(schema: {
366
+ name: string;
367
+ fields?: Record<string, any>;
368
+ tenancy?: any;
369
+ external?: {
370
+ remoteName?: string;
371
+ remoteSchema?: string;
372
+ columnMap?: Record<string, string>;
373
+ };
374
+ }): void;
302
375
  initObjects(objects: Array<{
303
376
  name: string;
304
377
  fields?: Record<string, any>;
@@ -404,6 +477,16 @@ declare class SqlDriver implements IDataDriver {
404
477
  * `initObjects`). Returns null when the builder is not table-scoped yet.
405
478
  */
406
479
  protected tableNameForBuilder(builder: any): string | null;
480
+ /**
481
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
482
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
483
+ * physical remote table, so a builder reports the remote name. Map it back to
484
+ * the object name for external objects; identity for managed ones (no reverse
485
+ * entry). Note datetime coercion is a SQLite-only concern (see
486
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
487
+ * exact where it matters.
488
+ */
489
+ protected coercionKey(builder: any): string | null;
407
490
  /**
408
491
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
409
492
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -463,6 +546,19 @@ declare class SqlDriver implements IDataDriver {
463
546
  private applyContainsLike;
464
547
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
465
548
  protected mapSortField(field: string): string;
549
+ /**
550
+ * Physical column for a logical field on an external object that declares an
551
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
552
+ * per-site resolution) when the object has no columnMap, so managed objects
553
+ * and external objects without a columnMap are byte-for-byte unchanged.
554
+ */
555
+ protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string;
556
+ /**
557
+ * Remap a write payload's logical field keys to physical remote columns for an
558
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
559
+ * (whose value coercion is keyed by logical field name).
560
+ */
561
+ protected applyWriteColumnMap(object: string, data: any): any;
466
562
  protected mapAggregateFunc(func: string): string;
467
563
  protected buildWindowFunction(spec: any): string;
468
564
  protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any): void;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
1
+ import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
3
  import knex, { Knex } from 'knex';
4
4
 
@@ -101,6 +101,20 @@ declare class SqlDriver implements IDataDriver {
101
101
  protected numericFields: Record<string, string[]>;
102
102
  protected dateFields: Record<string, Set<string>>;
103
103
  protected datetimeFields: Record<string, Set<string>>;
104
+ /**
105
+ * Federation read path (ADR-0015). For external objects whose physical
106
+ * remote table differs from the object name, these map between the two so
107
+ * {@link getBuilder} targets the remote table while the coercion maps above
108
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
109
+ * managed objects, so the managed query path is unchanged.
110
+ */
111
+ protected physicalTableByObject: Record<string, string>;
112
+ protected physicalSchemaByObject: Record<string, string>;
113
+ protected objectByPhysicalTable: Record<string, string>;
114
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
115
+ protected fieldColumnByObject: Record<string, Record<string, string>>;
116
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
117
+ protected columnFieldByObject: Record<string, Record<string, string>>;
104
118
  protected tablesWithTimestamps: Set<string>;
105
119
  /**
106
120
  * Autonumber field configs per table, captured during initObjects.
@@ -119,12 +133,19 @@ declare class SqlDriver implements IDataDriver {
119
133
  protected autoNumberFields: Record<string, Array<{
120
134
  name: string;
121
135
  format: string;
122
- prefix: string;
123
- padWidth: number;
136
+ tokens: AutonumberToken[];
124
137
  tenantField: string | null;
125
138
  }>>;
126
139
  /** Whether the sequences table has been ensured this process. */
127
140
  protected sequencesTableReady: boolean;
141
+ /**
142
+ * Whether `_objectstack_sequences` is the current `key_hash`-keyed shape.
143
+ * Set on a fresh create or a successful in-place migration. If a legacy table
144
+ * could NOT be migrated, this stays false: fixed-prefix sequences (empty
145
+ * scope) keep working via the legacy `(object, tenant_id, field)` key, while a
146
+ * per-scope write raises an actionable error rather than corrupting counters.
147
+ */
148
+ protected sequencesHasKeyHash: boolean;
128
149
  /** In-flight ensure promise; deduplicates concurrent first calls. */
129
150
  protected sequencesTableEnsurePromise: Promise<void> | null;
130
151
  /**
@@ -214,8 +235,34 @@ declare class SqlDriver implements IDataDriver {
214
235
  /**
215
236
  * Ensure the sequence-counter table exists. Idempotent and cheap after
216
237
  * the first call (cached via `sequencesTableReady`).
238
+ *
239
+ * The row key is `key_hash` — a SHA-256 of `(object, tenant_id, field, scope)`
240
+ * where `scope` is the rendered autonumber prefix (date/field tokens before
241
+ * the `{0000}` slot), so a new day/group/parent starts a fresh counter. A
242
+ * single 64-char hashed primary key (rather than the four raw columns, which
243
+ * blow past MySQL's 3072-byte index limit under utf8mb4 and bound how long a
244
+ * `{field}` scope may be) keys every dialect uniformly and lets `scope` be a
245
+ * generous non-indexed column. Fixed-prefix formats use the empty scope and
246
+ * keep their single global counter (backward compatible).
217
247
  */
218
248
  protected ensureSequencesTable(): Promise<void>;
249
+ /** SHA-256 of the composite counter key — the table's single-column PK. */
250
+ protected sequenceKeyHash(object: string, tenantId: string, field: string, scope: string): string;
251
+ /** Create the current `key_hash`-keyed sequences table shape. */
252
+ protected createSequencesTable(table: string): Promise<void>;
253
+ /**
254
+ * Migrate a pre-existing `_objectstack_sequences` table to the current
255
+ * `key_hash`-keyed shape. Handles both the original 3-column table (no
256
+ * `scope`) and an interim 4-column `(object, tenant_id, field, scope)` table:
257
+ * every legacy row is read, its `key_hash` computed in app code (no portable
258
+ * SQL hash exists), and re-inserted into a freshly built table that then
259
+ * replaces the original. Idempotent — a no-op once `key_hash` is present.
260
+ *
261
+ * If the rebuild fails, `sequencesHasKeyHash` stays false: fixed-prefix
262
+ * sequences keep working via the legacy key and per-scope writes error
263
+ * actionably (see getNextSequenceValue), rather than corrupting data.
264
+ */
265
+ protected ensureSequencesKeyHashShape(): Promise<void>;
219
266
  /**
220
267
  * Bootstrap helper: scan the data table for the highest numeric suffix
221
268
  * matching `prefix` (optionally scoped to a tenant). Used the first time
@@ -237,12 +284,14 @@ declare class SqlDriver implements IDataDriver {
237
284
  * Gaps are tolerated by design — a rolled-back insert "burns" a number,
238
285
  * matching standard sequence semantics.
239
286
  */
240
- protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction): Promise<number>;
287
+ protected getNextSequenceValue(object: string, tableName: string, field: string, prefix: string, tenantField: string | null, tenantId: string | null, parentTrx?: Knex.Transaction, scope?: string): Promise<number>;
241
288
  /**
242
- * For each `auto_number` field on the object that the caller did not
243
- * provide a value for, reserve the next sequence value scoped to the
244
- * record's tenant (or globally if the object has no tenant field) and
245
- * render `prefix + zero-padded(value)`.
289
+ * For each `auto_number` field the caller left empty, render the format and
290
+ * reserve the next counter value. The counter is scoped to the rendered
291
+ * prefix (date tokens like `{YYYYMMDD}` in the request's business timezone,
292
+ * plus `{field}` interpolation from the row), so it resets per period/group;
293
+ * the full rendered prefix bootstraps the counter from existing data, and the
294
+ * tenant scopes it for isolation.
246
295
  */
247
296
  protected fillAutoNumberFields(object: string, row: Record<string, any>, options?: DriverOptions): Promise<void>;
248
297
  update(object: string, id: string | number, data: Record<string, any>, options?: DriverOptions): Promise<any>;
@@ -299,6 +348,30 @@ declare class SqlDriver implements IDataDriver {
299
348
  /**
300
349
  * Batch-initialise tables from an array of object definitions.
301
350
  */
351
+ /**
352
+ * DDL-free metadata registration for a federated (external) object — the
353
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
354
+ *
355
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
356
+ * any non-`managed` driver, which left external objects with NO read-coercion
357
+ * metadata and the query path resolving to a table named after the object
358
+ * instead of its remote table. This populates the same coercion maps (keyed
359
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
360
+ * records the physical remote table (`external.remoteName`, optionally
361
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
362
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
363
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
364
+ */
365
+ registerExternalObject(schema: {
366
+ name: string;
367
+ fields?: Record<string, any>;
368
+ tenancy?: any;
369
+ external?: {
370
+ remoteName?: string;
371
+ remoteSchema?: string;
372
+ columnMap?: Record<string, string>;
373
+ };
374
+ }): void;
302
375
  initObjects(objects: Array<{
303
376
  name: string;
304
377
  fields?: Record<string, any>;
@@ -404,6 +477,16 @@ declare class SqlDriver implements IDataDriver {
404
477
  * `initObjects`). Returns null when the builder is not table-scoped yet.
405
478
  */
406
479
  protected tableNameForBuilder(builder: any): string | null;
480
+ /**
481
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
482
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
483
+ * physical remote table, so a builder reports the remote name. Map it back to
484
+ * the object name for external objects; identity for managed ones (no reverse
485
+ * entry). Note datetime coercion is a SQLite-only concern (see
486
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
487
+ * exact where it matters.
488
+ */
489
+ protected coercionKey(builder: any): string | null;
407
490
  /**
408
491
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
409
492
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -463,6 +546,19 @@ declare class SqlDriver implements IDataDriver {
463
546
  private applyContainsLike;
464
547
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
465
548
  protected mapSortField(field: string): string;
549
+ /**
550
+ * Physical column for a logical field on an external object that declares an
551
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
552
+ * per-site resolution) when the object has no columnMap, so managed objects
553
+ * and external objects without a columnMap are byte-for-byte unchanged.
554
+ */
555
+ protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string;
556
+ /**
557
+ * Remap a write payload's logical field keys to physical remote columns for an
558
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
559
+ * (whose value coercion is keyed by logical field name).
560
+ */
561
+ protected applyWriteColumnMap(object: string, data: any): any;
466
562
  protected mapAggregateFunc(func: string): string;
467
563
  protected buildWindowFunction(spec: any): string;
468
564
  protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any): void;