@objectstack/driver-sql 9.11.0 → 10.2.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,7 +1,115 @@
1
1
  import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
+ import { SchemaDiffEntry } from '@objectstack/spec/shared';
3
4
  import knex, { Knex } from 'knex';
4
5
 
6
+ /**
7
+ * Managed-datasource schema drift (issue #2186).
8
+ *
9
+ * The driver's `initObjects` sync is *additive-only*: it creates missing
10
+ * tables and adds missing columns, but never alters or drops existing ones.
11
+ * So a non-additive metadata change (relax `required`, change a type/length,
12
+ * drop or rename a field) silently diverges from an existing database — the
13
+ * served metadata says one thing and the physical column enforces another.
14
+ *
15
+ * This module is the single source of truth for *detecting* that divergence
16
+ * (metadata is authoritative on a `managed` datasource) and for *categorising*
17
+ * each divergence by how dangerous it is to reconcile:
18
+ *
19
+ * - `safe` — loosening that cannot lose data and cannot fail:
20
+ * relax NOT NULL → NULL, widen a varchar. Applied
21
+ * automatically by dev auto-reconcile (P2).
22
+ * - `needs_confirm`— a change a human should eyeball but that does not
23
+ * destroy data (e.g. a non-narrowing type change).
24
+ * - `destructive` — drops or tightenings that can lose data or fail:
25
+ * drop an orphaned column, narrow a varchar, add a
26
+ * NOT NULL constraint over possibly-null data. Only
27
+ * applied by `os migrate apply --allow-destructive`.
28
+ *
29
+ * The detector reuses {@link SchemaDiffEntry} (the same shape the external /
30
+ * federated validator emits, ADR-0015 §5.2) so CLI / Studio / audit can render
31
+ * managed and external drift uniformly.
32
+ */
33
+
34
+ type SqlDialectName = 'sqlite' | 'postgres' | 'mysql' | 'unknown';
35
+ type DriftCategory = 'safe' | 'needs_confirm' | 'destructive';
36
+ /** A reconcilable schema operation, machine-readable for the reconciler. */
37
+ type DriftOp = {
38
+ type: 'relax_not_null';
39
+ table: string;
40
+ column: string;
41
+ } | {
42
+ type: 'tighten_not_null';
43
+ table: string;
44
+ column: string;
45
+ } | {
46
+ type: 'widen_varchar';
47
+ table: string;
48
+ column: string;
49
+ to: number;
50
+ from?: number;
51
+ } | {
52
+ type: 'narrow_varchar';
53
+ table: string;
54
+ column: string;
55
+ to: number;
56
+ from?: number;
57
+ } | {
58
+ type: 'drop_column';
59
+ table: string;
60
+ column: string;
61
+ };
62
+ /**
63
+ * A managed-schema drift finding: a {@link SchemaDiffEntry} enriched with the
64
+ * owning table, a reconcile {@link DriftOp}, and a {@link DriftCategory}.
65
+ */
66
+ interface ManagedDriftEntry extends SchemaDiffEntry {
67
+ table: string;
68
+ category: DriftCategory;
69
+ op: DriftOp;
70
+ /** Human one-liner with an actionable hint. */
71
+ message: string;
72
+ }
73
+ /** Columns the driver creates unconditionally — never metadata fields. */
74
+ declare const BUILTIN_COLUMNS: Set<string>;
75
+ /** Minimal shape of an introspected physical column (see SqlDriver.introspectColumns). */
76
+ interface PhysicalColumn {
77
+ name: string;
78
+ type: string;
79
+ nullable: boolean;
80
+ maxLength?: number;
81
+ }
82
+ /** Minimal shape of a metadata field definition. */
83
+ interface FieldDef {
84
+ type?: string;
85
+ required?: boolean;
86
+ multiple?: boolean;
87
+ maxLength?: number;
88
+ }
89
+ /**
90
+ * Does this metadata field materialise a physical column? Mirrors
91
+ * `SqlDriver.createColumn` exactly: `formula` is virtual (computed, no column);
92
+ * everything else — including `multiple` (a JSON column) — gets one.
93
+ */
94
+ declare function fieldHasColumn(field: FieldDef): boolean;
95
+ /**
96
+ * Diff one table's metadata fields against its physical columns and return the
97
+ * set of *drift* findings. Metadata is authoritative.
98
+ *
99
+ * Note: a metadata field with no physical column is NOT reported — the
100
+ * additive sync (`ALTER TABLE ADD COLUMN`) already covers added fields, so by
101
+ * the time this runs every expected column exists. We only surface the
102
+ * non-additive divergences the additive sync can never fix.
103
+ */
104
+ declare function diffManagedTable(args: {
105
+ table: string;
106
+ fields: Record<string, FieldDef>;
107
+ columns: PhysicalColumn[];
108
+ dialect: SqlDialectName;
109
+ }): ManagedDriftEntry[];
110
+ /** Stable de-dup / sort key for a drift entry. */
111
+ declare function driftKey(d: ManagedDriftEntry): string;
112
+
5
113
  /**
6
114
  * SQL Driver for ObjectStack
7
115
  *
@@ -44,6 +152,14 @@ interface IntrospectedSchema {
44
152
  */
45
153
  type SqlDriverConfig = Knex.Config & {
46
154
  schemaMode?: SchemaMode;
155
+ /**
156
+ * Dev-only schema auto-reconcile (issue #2186). When `'safe'`, `initObjects`
157
+ * automatically applies *non-destructive* alters (relax NOT NULL, widen
158
+ * varchar) so an existing database self-heals after a metadata change
159
+ * loosens a constraint. `'off'` (default) only warns. Never applies
160
+ * destructive DDL, and is force-disabled when `NODE_ENV==='production'`.
161
+ */
162
+ autoMigrate?: 'off' | 'safe';
47
163
  };
48
164
  /**
49
165
  * SQL Driver for ObjectStack.
@@ -101,6 +217,20 @@ declare class SqlDriver implements IDataDriver {
101
217
  protected numericFields: Record<string, string[]>;
102
218
  protected dateFields: Record<string, Set<string>>;
103
219
  protected datetimeFields: Record<string, Set<string>>;
220
+ /**
221
+ * Federation read path (ADR-0015). For external objects whose physical
222
+ * remote table differs from the object name, these map between the two so
223
+ * {@link getBuilder} targets the remote table while the coercion maps above
224
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
225
+ * managed objects, so the managed query path is unchanged.
226
+ */
227
+ protected physicalTableByObject: Record<string, string>;
228
+ protected physicalSchemaByObject: Record<string, string>;
229
+ protected objectByPhysicalTable: Record<string, string>;
230
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
231
+ protected fieldColumnByObject: Record<string, Record<string, string>>;
232
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
233
+ protected columnFieldByObject: Record<string, Record<string, string>>;
104
234
  protected tablesWithTimestamps: Set<string>;
105
235
  /**
106
236
  * Autonumber field configs per table, captured during initObjects.
@@ -157,6 +287,7 @@ declare class SqlDriver implements IDataDriver {
157
287
  */
158
288
  protected logger: {
159
289
  warn: (msg: string, meta?: any) => void;
290
+ info?: (msg: string, meta?: any) => void;
160
291
  };
161
292
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
162
293
  protected get isSqlite(): boolean;
@@ -198,6 +329,20 @@ declare class SqlDriver implements IDataDriver {
198
329
  * `'managed'` so existing callers are unaffected.
199
330
  */
200
331
  protected readonly schemaMode: SchemaMode;
332
+ /**
333
+ * Dev-only auto-reconcile policy (issue #2186). See {@link SqlDriverConfig.autoMigrate}.
334
+ */
335
+ protected readonly autoMigrate: 'off' | 'safe';
336
+ /**
337
+ * Metadata field defs for every table this driver manages, captured during
338
+ * `initObjects` (tableName → fields). The source of truth that
339
+ * {@link detectManagedDrift} diffs the physical schema against.
340
+ */
341
+ protected managedObjectFields: Map<string, Record<string, any>>;
342
+ /** Declared indexes per managed table (tableName → indexes[]), captured in `initObjects`. Used to recreate indexes after a SQLite table rebuild. */
343
+ protected managedObjectIndexes: Map<string, any[]>;
344
+ /** De-dup set for boot-time drift warnings (keyed by {@link driftKey}). */
345
+ protected driftWarned: Set<string>;
201
346
  constructor(config: SqlDriverConfig);
202
347
  /**
203
348
  * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
@@ -334,10 +479,89 @@ declare class SqlDriver implements IDataDriver {
334
479
  /**
335
480
  * Batch-initialise tables from an array of object definitions.
336
481
  */
482
+ /**
483
+ * DDL-free metadata registration for a federated (external) object — the
484
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
485
+ *
486
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
487
+ * any non-`managed` driver, which left external objects with NO read-coercion
488
+ * metadata and the query path resolving to a table named after the object
489
+ * instead of its remote table. This populates the same coercion maps (keyed
490
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
491
+ * records the physical remote table (`external.remoteName`, optionally
492
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
493
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
494
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
495
+ */
496
+ registerExternalObject(schema: {
497
+ name: string;
498
+ fields?: Record<string, any>;
499
+ tenancy?: any;
500
+ external?: {
501
+ remoteName?: string;
502
+ remoteSchema?: string;
503
+ columnMap?: Record<string, string>;
504
+ };
505
+ }): void;
337
506
  initObjects(objects: Array<{
338
507
  name: string;
339
508
  fields?: Record<string, any>;
340
509
  }>): Promise<void>;
510
+ /** Canonical dialect name for the drift differ. */
511
+ protected get dialectName(): SqlDialectName;
512
+ /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */
513
+ protected isProductionEnv(): boolean;
514
+ /** Diff one table's metadata fields against its physical columns. */
515
+ protected detectTableDrift(tableName: string, fields: Record<string, any>): Promise<ManagedDriftEntry[]>;
516
+ /**
517
+ * Detect every managed-schema divergence between metadata and the physical
518
+ * database. Metadata is the source of truth. Returns one entry per drift,
519
+ * sorted by table then column. Used by `os migrate` (P3) and tests.
520
+ *
521
+ * @param objects optional explicit object list; defaults to whatever
522
+ * `initObjects` last synced (captured in {@link managedObjectFields}).
523
+ */
524
+ detectManagedDrift(objects?: Array<{
525
+ name: string;
526
+ fields?: Record<string, any>;
527
+ }>): Promise<ManagedDriftEntry[]>;
528
+ /**
529
+ * Boot-time per-table drift handling (P1 + P2): detect divergence, in dev
530
+ * auto-reconcile the *safe* (loosening) subset when `autoMigrate==='safe'`,
531
+ * then WARN once per remaining divergence with an actionable hint.
532
+ */
533
+ protected reconcileAndWarnDrift(tableName: string, fields: Record<string, any>): Promise<void>;
534
+ /**
535
+ * Apply a set of drift entries to the physical schema. Destructive entries
536
+ * are skipped unless `allowDestructive` is set. Postgres/MySQL alter columns
537
+ * in place; SQLite (which cannot alter constraints in place) rebuilds each
538
+ * affected table (copy → swap) applying only the requested edits.
539
+ *
540
+ * @returns the entries actually applied and those skipped (e.g. destructive
541
+ * without `allowDestructive`, or unsupported on the dialect).
542
+ */
543
+ applyMigrationEntries(entries: ManagedDriftEntry[], opts?: {
544
+ allowDestructive?: boolean;
545
+ }): Promise<{
546
+ applied: ManagedDriftEntry[];
547
+ skipped: ManagedDriftEntry[];
548
+ }>;
549
+ /** Apply a single drift op in place (Postgres / MySQL). Returns false if unsupported. */
550
+ protected applyDriftOpInPlace(op: DriftOp): Promise<boolean>;
551
+ /**
552
+ * Rebuild a SQLite table applying a set of column edits (relax/tighten NOT
553
+ * NULL, drop column), preserving all other columns and their data. Follows
554
+ * the official SQLite procedure: create patched table → copy → drop → rename.
555
+ * varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored.
556
+ *
557
+ * Unique field-level constraints and declared indexes are recreated from
558
+ * metadata afterwards (the source of truth). DB-level foreign keys declared
559
+ * by `lookup` fields are not re-added (ObjectStack enforces relationships at
560
+ * the application layer, not via SQLite FK constraints).
561
+ */
562
+ protected rebuildSqliteTablePatched(table: string, ents: ManagedDriftEntry[]): Promise<void>;
563
+ /** Map an introspected SQLite column to a knex builder for the rebuilt table. */
564
+ protected buildRebuiltColumn(t: Knex.CreateTableBuilder, c: IntrospectedColumn): any;
341
565
  /**
342
566
  * Build a deterministic index name for a declared index so repeated
343
567
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -439,6 +663,16 @@ declare class SqlDriver implements IDataDriver {
439
663
  * `initObjects`). Returns null when the builder is not table-scoped yet.
440
664
  */
441
665
  protected tableNameForBuilder(builder: any): string | null;
666
+ /**
667
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
668
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
669
+ * physical remote table, so a builder reports the remote name. Map it back to
670
+ * the object name for external objects; identity for managed ones (no reverse
671
+ * entry). Note datetime coercion is a SQLite-only concern (see
672
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
673
+ * exact where it matters.
674
+ */
675
+ protected coercionKey(builder: any): string | null;
442
676
  /**
443
677
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
444
678
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -498,6 +732,19 @@ declare class SqlDriver implements IDataDriver {
498
732
  private applyContainsLike;
499
733
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
500
734
  protected mapSortField(field: string): string;
735
+ /**
736
+ * Physical column for a logical field on an external object that declares an
737
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
738
+ * per-site resolution) when the object has no columnMap, so managed objects
739
+ * and external objects without a columnMap are byte-for-byte unchanged.
740
+ */
741
+ protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string;
742
+ /**
743
+ * Remap a write payload's logical field keys to physical remote columns for an
744
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
745
+ * (whose value coercion is keyed by logical field name).
746
+ */
747
+ protected applyWriteColumnMap(object: string, data: any): any;
501
748
  protected mapAggregateFunc(func: string): string;
502
749
  protected buildWindowFunction(spec: any): string;
503
750
  protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any): void;
@@ -518,4 +765,4 @@ declare const _default: {
518
765
  onEnable: (context: any) => Promise<void>;
519
766
  };
520
767
 
521
- export { type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, type IntrospectedTable, SqlDriver, type SqlDriverConfig, _default as default };
768
+ export { BUILTIN_COLUMNS, type DriftCategory, type FieldDef as DriftFieldDef, type DriftOp, type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, type IntrospectedTable, type ManagedDriftEntry, type PhysicalColumn, type SqlDialectName, SqlDriver, type SqlDriverConfig, _default as default, diffManagedTable, driftKey, fieldHasColumn };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,115 @@
1
1
  import { AutonumberToken, SchemaMode, QueryAST, DriverOptions } from '@objectstack/spec/data';
2
2
  import { IDataDriver } from '@objectstack/spec/contracts';
3
+ import { SchemaDiffEntry } from '@objectstack/spec/shared';
3
4
  import knex, { Knex } from 'knex';
4
5
 
6
+ /**
7
+ * Managed-datasource schema drift (issue #2186).
8
+ *
9
+ * The driver's `initObjects` sync is *additive-only*: it creates missing
10
+ * tables and adds missing columns, but never alters or drops existing ones.
11
+ * So a non-additive metadata change (relax `required`, change a type/length,
12
+ * drop or rename a field) silently diverges from an existing database — the
13
+ * served metadata says one thing and the physical column enforces another.
14
+ *
15
+ * This module is the single source of truth for *detecting* that divergence
16
+ * (metadata is authoritative on a `managed` datasource) and for *categorising*
17
+ * each divergence by how dangerous it is to reconcile:
18
+ *
19
+ * - `safe` — loosening that cannot lose data and cannot fail:
20
+ * relax NOT NULL → NULL, widen a varchar. Applied
21
+ * automatically by dev auto-reconcile (P2).
22
+ * - `needs_confirm`— a change a human should eyeball but that does not
23
+ * destroy data (e.g. a non-narrowing type change).
24
+ * - `destructive` — drops or tightenings that can lose data or fail:
25
+ * drop an orphaned column, narrow a varchar, add a
26
+ * NOT NULL constraint over possibly-null data. Only
27
+ * applied by `os migrate apply --allow-destructive`.
28
+ *
29
+ * The detector reuses {@link SchemaDiffEntry} (the same shape the external /
30
+ * federated validator emits, ADR-0015 §5.2) so CLI / Studio / audit can render
31
+ * managed and external drift uniformly.
32
+ */
33
+
34
+ type SqlDialectName = 'sqlite' | 'postgres' | 'mysql' | 'unknown';
35
+ type DriftCategory = 'safe' | 'needs_confirm' | 'destructive';
36
+ /** A reconcilable schema operation, machine-readable for the reconciler. */
37
+ type DriftOp = {
38
+ type: 'relax_not_null';
39
+ table: string;
40
+ column: string;
41
+ } | {
42
+ type: 'tighten_not_null';
43
+ table: string;
44
+ column: string;
45
+ } | {
46
+ type: 'widen_varchar';
47
+ table: string;
48
+ column: string;
49
+ to: number;
50
+ from?: number;
51
+ } | {
52
+ type: 'narrow_varchar';
53
+ table: string;
54
+ column: string;
55
+ to: number;
56
+ from?: number;
57
+ } | {
58
+ type: 'drop_column';
59
+ table: string;
60
+ column: string;
61
+ };
62
+ /**
63
+ * A managed-schema drift finding: a {@link SchemaDiffEntry} enriched with the
64
+ * owning table, a reconcile {@link DriftOp}, and a {@link DriftCategory}.
65
+ */
66
+ interface ManagedDriftEntry extends SchemaDiffEntry {
67
+ table: string;
68
+ category: DriftCategory;
69
+ op: DriftOp;
70
+ /** Human one-liner with an actionable hint. */
71
+ message: string;
72
+ }
73
+ /** Columns the driver creates unconditionally — never metadata fields. */
74
+ declare const BUILTIN_COLUMNS: Set<string>;
75
+ /** Minimal shape of an introspected physical column (see SqlDriver.introspectColumns). */
76
+ interface PhysicalColumn {
77
+ name: string;
78
+ type: string;
79
+ nullable: boolean;
80
+ maxLength?: number;
81
+ }
82
+ /** Minimal shape of a metadata field definition. */
83
+ interface FieldDef {
84
+ type?: string;
85
+ required?: boolean;
86
+ multiple?: boolean;
87
+ maxLength?: number;
88
+ }
89
+ /**
90
+ * Does this metadata field materialise a physical column? Mirrors
91
+ * `SqlDriver.createColumn` exactly: `formula` is virtual (computed, no column);
92
+ * everything else — including `multiple` (a JSON column) — gets one.
93
+ */
94
+ declare function fieldHasColumn(field: FieldDef): boolean;
95
+ /**
96
+ * Diff one table's metadata fields against its physical columns and return the
97
+ * set of *drift* findings. Metadata is authoritative.
98
+ *
99
+ * Note: a metadata field with no physical column is NOT reported — the
100
+ * additive sync (`ALTER TABLE ADD COLUMN`) already covers added fields, so by
101
+ * the time this runs every expected column exists. We only surface the
102
+ * non-additive divergences the additive sync can never fix.
103
+ */
104
+ declare function diffManagedTable(args: {
105
+ table: string;
106
+ fields: Record<string, FieldDef>;
107
+ columns: PhysicalColumn[];
108
+ dialect: SqlDialectName;
109
+ }): ManagedDriftEntry[];
110
+ /** Stable de-dup / sort key for a drift entry. */
111
+ declare function driftKey(d: ManagedDriftEntry): string;
112
+
5
113
  /**
6
114
  * SQL Driver for ObjectStack
7
115
  *
@@ -44,6 +152,14 @@ interface IntrospectedSchema {
44
152
  */
45
153
  type SqlDriverConfig = Knex.Config & {
46
154
  schemaMode?: SchemaMode;
155
+ /**
156
+ * Dev-only schema auto-reconcile (issue #2186). When `'safe'`, `initObjects`
157
+ * automatically applies *non-destructive* alters (relax NOT NULL, widen
158
+ * varchar) so an existing database self-heals after a metadata change
159
+ * loosens a constraint. `'off'` (default) only warns. Never applies
160
+ * destructive DDL, and is force-disabled when `NODE_ENV==='production'`.
161
+ */
162
+ autoMigrate?: 'off' | 'safe';
47
163
  };
48
164
  /**
49
165
  * SQL Driver for ObjectStack.
@@ -101,6 +217,20 @@ declare class SqlDriver implements IDataDriver {
101
217
  protected numericFields: Record<string, string[]>;
102
218
  protected dateFields: Record<string, Set<string>>;
103
219
  protected datetimeFields: Record<string, Set<string>>;
220
+ /**
221
+ * Federation read path (ADR-0015). For external objects whose physical
222
+ * remote table differs from the object name, these map between the two so
223
+ * {@link getBuilder} targets the remote table while the coercion maps above
224
+ * stay keyed by OBJECT name (matching formatInput/formatOutput). Empty for
225
+ * managed objects, so the managed query path is unchanged.
226
+ */
227
+ protected physicalTableByObject: Record<string, string>;
228
+ protected physicalSchemaByObject: Record<string, string>;
229
+ protected objectByPhysicalTable: Record<string, string>;
230
+ /** External columnMap (ADR-0015): logical field -> physical remote column (for WHERE/ORDER BY/writes). */
231
+ protected fieldColumnByObject: Record<string, Record<string, string>>;
232
+ /** External columnMap inverse: physical remote column -> logical field (for read output remap). */
233
+ protected columnFieldByObject: Record<string, Record<string, string>>;
104
234
  protected tablesWithTimestamps: Set<string>;
105
235
  /**
106
236
  * Autonumber field configs per table, captured during initObjects.
@@ -157,6 +287,7 @@ declare class SqlDriver implements IDataDriver {
157
287
  */
158
288
  protected logger: {
159
289
  warn: (msg: string, meta?: any) => void;
290
+ info?: (msg: string, meta?: any) => void;
160
291
  };
161
292
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
162
293
  protected get isSqlite(): boolean;
@@ -198,6 +329,20 @@ declare class SqlDriver implements IDataDriver {
198
329
  * `'managed'` so existing callers are unaffected.
199
330
  */
200
331
  protected readonly schemaMode: SchemaMode;
332
+ /**
333
+ * Dev-only auto-reconcile policy (issue #2186). See {@link SqlDriverConfig.autoMigrate}.
334
+ */
335
+ protected readonly autoMigrate: 'off' | 'safe';
336
+ /**
337
+ * Metadata field defs for every table this driver manages, captured during
338
+ * `initObjects` (tableName → fields). The source of truth that
339
+ * {@link detectManagedDrift} diffs the physical schema against.
340
+ */
341
+ protected managedObjectFields: Map<string, Record<string, any>>;
342
+ /** Declared indexes per managed table (tableName → indexes[]), captured in `initObjects`. Used to recreate indexes after a SQLite table rebuild. */
343
+ protected managedObjectIndexes: Map<string, any[]>;
344
+ /** De-dup set for boot-time drift warnings (keyed by {@link driftKey}). */
345
+ protected driftWarned: Set<string>;
201
346
  constructor(config: SqlDriverConfig);
202
347
  /**
203
348
  * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
@@ -334,10 +479,89 @@ declare class SqlDriver implements IDataDriver {
334
479
  /**
335
480
  * Batch-initialise tables from an array of object definitions.
336
481
  */
482
+ /**
483
+ * DDL-free metadata registration for a federated (external) object — the
484
+ * read-path counterpart to {@link initObjects} (ADR-0015 federation).
485
+ *
486
+ * `initObjects` is gated by `assertSchemaMutable` and therefore throws for
487
+ * any non-`managed` driver, which left external objects with NO read-coercion
488
+ * metadata and the query path resolving to a table named after the object
489
+ * instead of its remote table. This populates the same coercion maps (keyed
490
+ * by OBJECT name, matching formatInput/formatOutput/coerceFilterValue) and
491
+ * records the physical remote table (`external.remoteName`, optionally
492
+ * `external.remoteSchema`) so {@link getBuilder} targets it — WITHOUT running
493
+ * any DDL (createTable/alterTable/columnInfo). Keep the field-classification
494
+ * below in sync with initObjects() if the field-type -> storage mapping changes.
495
+ */
496
+ registerExternalObject(schema: {
497
+ name: string;
498
+ fields?: Record<string, any>;
499
+ tenancy?: any;
500
+ external?: {
501
+ remoteName?: string;
502
+ remoteSchema?: string;
503
+ columnMap?: Record<string, string>;
504
+ };
505
+ }): void;
337
506
  initObjects(objects: Array<{
338
507
  name: string;
339
508
  fields?: Record<string, any>;
340
509
  }>): Promise<void>;
510
+ /** Canonical dialect name for the drift differ. */
511
+ protected get dialectName(): SqlDialectName;
512
+ /** True only when running under `NODE_ENV=production` — auto-DDL is force-disabled there. */
513
+ protected isProductionEnv(): boolean;
514
+ /** Diff one table's metadata fields against its physical columns. */
515
+ protected detectTableDrift(tableName: string, fields: Record<string, any>): Promise<ManagedDriftEntry[]>;
516
+ /**
517
+ * Detect every managed-schema divergence between metadata and the physical
518
+ * database. Metadata is the source of truth. Returns one entry per drift,
519
+ * sorted by table then column. Used by `os migrate` (P3) and tests.
520
+ *
521
+ * @param objects optional explicit object list; defaults to whatever
522
+ * `initObjects` last synced (captured in {@link managedObjectFields}).
523
+ */
524
+ detectManagedDrift(objects?: Array<{
525
+ name: string;
526
+ fields?: Record<string, any>;
527
+ }>): Promise<ManagedDriftEntry[]>;
528
+ /**
529
+ * Boot-time per-table drift handling (P1 + P2): detect divergence, in dev
530
+ * auto-reconcile the *safe* (loosening) subset when `autoMigrate==='safe'`,
531
+ * then WARN once per remaining divergence with an actionable hint.
532
+ */
533
+ protected reconcileAndWarnDrift(tableName: string, fields: Record<string, any>): Promise<void>;
534
+ /**
535
+ * Apply a set of drift entries to the physical schema. Destructive entries
536
+ * are skipped unless `allowDestructive` is set. Postgres/MySQL alter columns
537
+ * in place; SQLite (which cannot alter constraints in place) rebuilds each
538
+ * affected table (copy → swap) applying only the requested edits.
539
+ *
540
+ * @returns the entries actually applied and those skipped (e.g. destructive
541
+ * without `allowDestructive`, or unsupported on the dialect).
542
+ */
543
+ applyMigrationEntries(entries: ManagedDriftEntry[], opts?: {
544
+ allowDestructive?: boolean;
545
+ }): Promise<{
546
+ applied: ManagedDriftEntry[];
547
+ skipped: ManagedDriftEntry[];
548
+ }>;
549
+ /** Apply a single drift op in place (Postgres / MySQL). Returns false if unsupported. */
550
+ protected applyDriftOpInPlace(op: DriftOp): Promise<boolean>;
551
+ /**
552
+ * Rebuild a SQLite table applying a set of column edits (relax/tighten NOT
553
+ * NULL, drop column), preserving all other columns and their data. Follows
554
+ * the official SQLite procedure: create patched table → copy → drop → rename.
555
+ * varchar widen/narrow are no-ops on SQLite (dynamic typing) and ignored.
556
+ *
557
+ * Unique field-level constraints and declared indexes are recreated from
558
+ * metadata afterwards (the source of truth). DB-level foreign keys declared
559
+ * by `lookup` fields are not re-added (ObjectStack enforces relationships at
560
+ * the application layer, not via SQLite FK constraints).
561
+ */
562
+ protected rebuildSqliteTablePatched(table: string, ents: ManagedDriftEntry[]): Promise<void>;
563
+ /** Map an introspected SQLite column to a knex builder for the rebuilt table. */
564
+ protected buildRebuiltColumn(t: Knex.CreateTableBuilder, c: IntrospectedColumn): any;
341
565
  /**
342
566
  * Build a deterministic index name for a declared index so repeated
343
567
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -439,6 +663,16 @@ declare class SqlDriver implements IDataDriver {
439
663
  * `initObjects`). Returns null when the builder is not table-scoped yet.
440
664
  */
441
665
  protected tableNameForBuilder(builder: any): string | null;
666
+ /**
667
+ * Coercion-map key for a builder. Coercion maps (date/datetime) are keyed by
668
+ * OBJECT name, but after the federation change {@link getBuilder} targets the
669
+ * physical remote table, so a builder reports the remote name. Map it back to
670
+ * the object name for external objects; identity for managed ones (no reverse
671
+ * entry). Note datetime coercion is a SQLite-only concern (see
672
+ * coerceFilterValue), and SQLite external tables are bare-named, so this is
673
+ * exact where it matters.
674
+ */
675
+ protected coercionKey(builder: any): string | null;
442
676
  /**
443
677
  * Collapse a `Field.date` value to a timezone-naive `YYYY-MM-DD`
444
678
  * calendar-day string (ADR-0053 Phase 1). A `Date` collapses to its UTC
@@ -498,6 +732,19 @@ declare class SqlDriver implements IDataDriver {
498
732
  private applyContainsLike;
499
733
  protected applyFilterCondition(builder: Knex.QueryBuilder, condition: any, logicalOp?: 'and' | 'or', tableHint?: string | null): void;
500
734
  protected mapSortField(field: string): string;
735
+ /**
736
+ * Physical column for a logical field on an external object that declares an
737
+ * `external.columnMap` (ADR-0015). Returns `fallback` (the caller's existing
738
+ * per-site resolution) when the object has no columnMap, so managed objects
739
+ * and external objects without a columnMap are byte-for-byte unchanged.
740
+ */
741
+ protected remoteColumn(object: string | null | undefined, field: string, fallback: string): string;
742
+ /**
743
+ * Remap a write payload's logical field keys to physical remote columns for an
744
+ * external object with a columnMap. No-op otherwise. Applied AFTER formatInput
745
+ * (whose value coercion is keyed by logical field name).
746
+ */
747
+ protected applyWriteColumnMap(object: string, data: any): any;
501
748
  protected mapAggregateFunc(func: string): string;
502
749
  protected buildWindowFunction(spec: any): string;
503
750
  protected createColumn(table: Knex.CreateTableBuilder, name: string, field: any): void;
@@ -518,4 +765,4 @@ declare const _default: {
518
765
  onEnable: (context: any) => Promise<void>;
519
766
  };
520
767
 
521
- export { type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, type IntrospectedTable, SqlDriver, type SqlDriverConfig, _default as default };
768
+ export { BUILTIN_COLUMNS, type DriftCategory, type FieldDef as DriftFieldDef, type DriftOp, type IntrospectedColumn, type IntrospectedForeignKey, type IntrospectedSchema, type IntrospectedTable, type ManagedDriftEntry, type PhysicalColumn, type SqlDialectName, SqlDriver, type SqlDriverConfig, _default as default, diffManagedTable, driftKey, fieldHasColumn };