@objectstack/driver-sql 10.0.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.
@@ -171,6 +287,7 @@ declare class SqlDriver implements IDataDriver {
171
287
  */
172
288
  protected logger: {
173
289
  warn: (msg: string, meta?: any) => void;
290
+ info?: (msg: string, meta?: any) => void;
174
291
  };
175
292
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
176
293
  protected get isSqlite(): boolean;
@@ -212,6 +329,20 @@ declare class SqlDriver implements IDataDriver {
212
329
  * `'managed'` so existing callers are unaffected.
213
330
  */
214
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>;
215
346
  constructor(config: SqlDriverConfig);
216
347
  /**
217
348
  * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
@@ -376,6 +507,61 @@ declare class SqlDriver implements IDataDriver {
376
507
  name: string;
377
508
  fields?: Record<string, any>;
378
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;
379
565
  /**
380
566
  * Build a deterministic index name for a declared index so repeated
381
567
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -579,4 +765,4 @@ declare const _default: {
579
765
  onEnable: (context: any) => Promise<void>;
580
766
  };
581
767
 
582
- 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.
@@ -171,6 +287,7 @@ declare class SqlDriver implements IDataDriver {
171
287
  */
172
288
  protected logger: {
173
289
  warn: (msg: string, meta?: any) => void;
290
+ info?: (msg: string, meta?: any) => void;
174
291
  };
175
292
  /** Whether the underlying database is a SQLite variant (sqlite3 or better-sqlite3). */
176
293
  protected get isSqlite(): boolean;
@@ -212,6 +329,20 @@ declare class SqlDriver implements IDataDriver {
212
329
  * `'managed'` so existing callers are unaffected.
213
330
  */
214
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>;
215
346
  constructor(config: SqlDriverConfig);
216
347
  /**
217
348
  * DDL gate (ADR-0015 §5.1). Single choke-point asserting that
@@ -376,6 +507,61 @@ declare class SqlDriver implements IDataDriver {
376
507
  name: string;
377
508
  fields?: Record<string, any>;
378
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;
379
565
  /**
380
566
  * Build a deterministic index name for a declared index so repeated
381
567
  * `initObjects` runs converge on the same identifier (and can detect an
@@ -579,4 +765,4 @@ declare const _default: {
579
765
  onEnable: (context: any) => Promise<void>;
580
766
  };
581
767
 
582
- 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 };