@jarenjs/db 0.86.0 → 0.89.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.
@@ -3,20 +3,56 @@
3
3
  import { hashContent } from '@jarenjs/core/string';
4
4
  import { canonicalizeJson } from '@jarenjs/json/canonical';
5
5
  import { DbCompileError } from './errors.js';
6
- import { defineTable, planTable } from './dialects/sqlite-schema.js';
6
+ import { defineTable, planTable, schemaChangeSql } from './dialects/sqlite-schema.js';
7
7
  import { relationalEmitter, relationalIdentifier as q, sql } from './dialects/sqlite-relational.js';
8
8
  import { sqliteDialect as dialect, sqliteTableMigration } from './dialects/sqlite.js';
9
9
  import { sqlTokens } from './dialects/check-read.js';
10
+ import { ENGINE_TABLES } from './engine-metadata.js';
11
+ import { withForeignKeySettings } from './foreign-key-scope.js';
10
12
 
11
13
  const refuse = (message) => { throw new DbCompileError('JD0021', message); };
12
14
  const fingerprint = (v) => canonicalizeJson(v);
13
- const schema = (connection) => connection.prepare(sqliteTableMigration.schema()).all([]).map((v) => ({ ...v }));
15
+ const schema = (connection) => connection.prepare(sqliteTableMigration.schema()).all([])
16
+ .filter((v) => !ENGINE_TABLES.has(v.name) && !ENGINE_TABLES.has(v.tbl_name)).map((v) => ({ ...v }));
14
17
  const createdSql = (text) => text.replace(/^CREATE (TABLE|(?:UNIQUE )?INDEX|TRIGGER) IF NOT EXISTS /i, 'CREATE $1 ');
15
18
  const owned = (objects, table) => objects.filter((o) => o.tbl_name === table).map((o) => [o.type, o.name, o.sql]).sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
16
19
  const sync = (connection) => {
17
20
  if (connection.dialect.name !== 'sqlite' || !connection.synchronous || connection.mustQueue) refuse('table migration requires an available synchronous SQLite connection');
18
21
  };
19
22
 
23
+ /** Connection settings that govern the meaning of an additive/drop/rename plan. */
24
+ function schemaSettings(connection) {
25
+ return ['foreign_keys', 'legacy_alter_table', 'schema_version'].map((name) =>
26
+ connection.prepare(dialect.introspect.pragma(name)).get([])[name]);
27
+ }
28
+
29
+ /** Review one native main-schema change without changing the database.
30
+ * Plans describe one source snapshot; callers own durable migration receipts.
31
+ * @param {any} connection @param {any} operation */
32
+ export function planSchemaChange(connection, operation) {
33
+ sync(connection);
34
+ const text = schemaChangeSql(operation);
35
+ const body = { version: 1, operation: structuredClone(operation), sql: text,
36
+ source: schema(connection), settings: schemaSettings(connection) };
37
+ return { ...body, checksum: fingerprint({ ...body, operation: text }) };
38
+ }
39
+
40
+ /** Apply a reviewed native change atomically, refusing schema/settings drift.
41
+ * Replan after any schema change; only explicit drop ifExists handles absence.
42
+ * @param {any} connection @param {ReturnType<typeof planSchemaChange>} plan */
43
+ export function applySchemaChange(connection, plan) {
44
+ sync(connection);
45
+ const { checksum, ...body } = plan;
46
+ if (body.version !== 1 || checksum !== fingerprint({ ...body, operation: plan.sql }) || plan.sql !== schemaChangeSql(plan.operation)) refuse('schema change checksum or statement differs');
47
+ return connection.transaction(() => {
48
+ const before = schema(connection);
49
+ if (fingerprint(before) !== fingerprint(plan.source) || fingerprint(schemaSettings(connection)) !== fingerprint(plan.settings))
50
+ refuse('source schema or connection settings changed after planning');
51
+ connection.exec(plan.sql);
52
+ return { changed: fingerprint(before) === fingerprint(schema(connection)) ? 0 : 1 };
53
+ }, undefined, 'immediate');
54
+ }
55
+
20
56
  /** Inspect a live schema and generate a table plan without changing it.
21
57
  * Rebuilds require explicit opt-in. Unlisted indexes and triggers are preserved.
22
58
  * @param {any} connection @param {any} definition
@@ -71,7 +107,15 @@ export function planTableMigration(connection, definition, options) {
71
107
  // Preserve hidden rowids too, including text/composite-key rowid tables.
72
108
  const tableInfo = existing ? connection.prepare(sqliteTableMigration.tableList()).all([]).find((t) => t.schema === 'main' && t.name === target.name) : null;
73
109
  if (rebuild && !!tableInfo.wr !== !!target.withoutRowid) refuse('rebuild cannot change rowid ownership');
74
- if (rebuild && !tableInfo.wr && !(oldKey.length === 1 && oldColumns.find((c) => c.name === oldKey[0]).type.toUpperCase() === 'INTEGER')) {
110
+ // INTEGER PRIMARY KEY DESC has a separate primary-key index and a hidden
111
+ // rowid. The declared type alone cannot establish rowid ownership.
112
+ const sourceAlias = rebuild && !tableInfo.wr && oldKey.length === 1
113
+ && oldColumns.find((c) => c.name === oldKey[0]).type.toUpperCase() === 'INTEGER'
114
+ && !connection.prepare(dialect.introspect.indexes(target.name)).all([]).some((index) => index.origin === 'pk');
115
+ const targetAlias = !target.withoutRowid && target.primaryKey?.length === 1
116
+ && target.columns.find((c) => c.name.toLowerCase() === target.primaryKey[0].toLowerCase()).type === 'INTEGER';
117
+ if (rebuild && sourceAlias !== !!targetAlias) refuse('rebuild cannot change rowid ownership');
118
+ if (rebuild && !tableInfo.wr && !sourceAlias) {
75
119
  const rowid = ['rowid', '_rowid_', 'oid'].find((name) => !oldColumns.some((c) => c.name.toLowerCase() === name)
76
120
  && !target.columns.some((c) => c.name.toLowerCase() === name));
77
121
  if (!rowid) refuse('the source shadows every rowid alias');
@@ -124,7 +168,7 @@ export function applyTableMigration(connection, plan) {
124
168
  if (connection.prepare(dialect.pragma.foreignKeyCheck()).get([])) refuse('migration violates foreign-key references');
125
169
  if (fingerprint(owned(schema(connection), plan.table)) !== fingerprint(owned(plan.after, plan.table))) refuse('migrated schema differs from the reviewed target');
126
170
  return { changed: plan.statements.length + plan.finish.length };
127
- }, { mode: 'immediate' });
171
+ }, undefined, 'immediate');
128
172
  return plan.rebuild ? withForeignKeysSuspended(connection, run) : run();
129
173
  }
130
174
 
@@ -135,13 +179,7 @@ export function withForeignKeysSuspended(connection, fn) {
135
179
  sync(connection);
136
180
  if (typeof fn !== 'function' || Object.prototype.toString.call(fn) === '[object AsyncFunction]')
137
181
  refuse('a physical migration scope requires a synchronous callback');
138
- const foreignKeys = connection.prepare(dialect.introspect.pragma('foreign_keys')).get([]).foreign_keys;
139
- const legacy = connection.prepare(dialect.introspect.pragma('legacy_alter_table')).get([]).legacy_alter_table;
140
- try {
141
- connection.exec(dialect.pragma.foreignKeys(false));
142
- if (connection.prepare(dialect.introspect.pragma('foreign_keys')).get([]).foreign_keys !== 0) refuse('foreign_keys cannot change inside a transaction; establish the outer migration scope first');
143
- connection.exec(dialect.pragma.set('legacy_alter_table', 'ON'));
144
- return connection.transaction(() => {
182
+ return withForeignKeySettings(connection, () => connection.transaction(() => {
145
183
  const result = fn();
146
184
  if (result != null && typeof result.then === 'function') {
147
185
  Promise.resolve(result).catch(() => {});
@@ -149,10 +187,5 @@ export function withForeignKeysSuspended(connection, fn) {
149
187
  }
150
188
  if (connection.prepare(dialect.pragma.foreignKeyCheck()).get([])) refuse('migration violates foreign-key references');
151
189
  return result;
152
- }, { mode: 'immediate' });
153
- }
154
- finally {
155
- connection.exec(dialect.pragma.set('legacy_alter_table', legacy ? 'ON' : 'OFF'));
156
- connection.exec(dialect.pragma.foreignKeys(!!foreignKeys));
157
- }
190
+ }, undefined, 'immediate'));
158
191
  }
package/types/index.d.ts CHANGED
@@ -1169,6 +1169,10 @@ export interface Dialect {
1169
1169
  export interface Driver {
1170
1170
  readonly name: string;
1171
1171
  readonly dialect: Dialect;
1172
+ /** Optional storage identity for shadow isolation. Equal non-null values
1173
+ * identify the same database; private memory databases return null. Native
1174
+ * SQLite bindings use filesystem device/inode identity. */
1175
+ databaseIdentity?(connection: unknown): string | null | Promise<string | null>;
1172
1176
  /** Open a connection (value-or-promise) at `path` (`':memory:'` for
1173
1177
  * none) with the driver's own options. */
1174
1178
  open(path: string, options?: unknown): unknown;
@@ -1259,6 +1263,37 @@ export interface MigrationTarget {
1259
1263
  path?: string;
1260
1264
  /** `PRAGMA busy_timeout` for the run's connection, ms (default 5000). */
1261
1265
  busyTimeout?: number;
1266
+ connection?: never;
1267
+ }
1268
+
1269
+ /** A caller-owned connection, never closed or reopened by migration entry points. */
1270
+ export interface BorrowedMigrationTarget {
1271
+ connection: unknown;
1272
+ driver?: never;
1273
+ path?: never;
1274
+ busyTimeout?: never;
1275
+ }
1276
+
1277
+ /** One physical catalog object. Some dialects cannot supply declaration text. */
1278
+ export interface SchemaObject {
1279
+ readonly type: 'table' | 'view' | 'index' | 'trigger';
1280
+ readonly name: string;
1281
+ readonly owner: string;
1282
+ readonly sql: string | null;
1283
+ }
1284
+
1285
+ export interface SchemaInventory {
1286
+ readonly tables: readonly unknown[];
1287
+ readonly views: readonly string[];
1288
+ readonly objects: readonly SchemaObject[];
1289
+ }
1290
+
1291
+ /** Complete reviewed SQLite objects in an explicitly owned scope.
1292
+ * Every object must carry SQL text; missing declaration text refuses at runtime. */
1293
+ export interface PhysicalMigrationTarget {
1294
+ readonly objects: readonly SchemaObject[];
1295
+ /** Defaults to the object owners; an absent named table records an intended drop. */
1296
+ readonly tables?: readonly string[];
1262
1297
  }
1263
1298
 
1264
1299
  /** One progress event: the migration and collection a data step is
@@ -1302,7 +1337,7 @@ export declare function introspectModel(connection: unknown, options?: {
1302
1337
  * model cannot declare. */
1303
1338
  export declare function readSchema(connection: unknown, options?: {
1304
1339
  tables?: readonly string[];
1305
- }): unknown;
1340
+ }): SchemaInventory | Promise<SchemaInventory>;
1306
1341
 
1307
1342
  export interface AssertionBounds {
1308
1343
  maxRows?: number | null;
@@ -1328,6 +1363,10 @@ export interface MigrateOptions {
1328
1363
  dryRun?: boolean;
1329
1364
  batchSize?: number;
1330
1365
  shadow?: boolean;
1366
+ /** Complete owned target; also checked when there are no pending steps. */
1367
+ physicalTarget?: PhysicalMigrationTarget;
1368
+ /** Initialize the disposable shadow with the actual historical schema and rows. */
1369
+ shadowFixture?: (connection: unknown) => unknown;
1331
1370
  /** Where the shadow replay runs (default `':memory:'`). */
1332
1371
  shadowPath?: string;
1333
1372
  /** The host's declared index-expression functions, by name — the same
@@ -1337,7 +1376,7 @@ export interface MigrateOptions {
1337
1376
  * A file engine's shadow is another file; a SERVER engine's is another
1338
1377
  * schema, and only the host can name one — the baseline shape the
1339
1378
  * replay creates would otherwise collide with the real store's. */
1340
- shadowDriver?: unknown;
1379
+ shadowDriver?: Driver;
1341
1380
  /** Called once per batch a data step walks (transform, derive, or a
1342
1381
  * per-document assertion). */
1343
1382
  onProgress?: (progress: MigrationProgress) => void;
@@ -1381,7 +1420,33 @@ export declare function classifyAssertion(query: unknown, options?: { expect?: s
1381
1420
 
1382
1421
  export declare function migrate(
1383
1422
  target: MigrationTarget, migrations: readonly unknown[], options: MigrateOptions,
1384
- ): Promise<unknown>;
1423
+ ): Promise<MigrationResult>;
1424
+ /** Borrowed synchronous work stays synchronous when shadow replay is disabled;
1425
+ * async connections or hooks retain their promise boundary. */
1426
+ export declare function migrate(
1427
+ target: BorrowedMigrationTarget, migrations: readonly unknown[], options: MigrateOptions,
1428
+ ): MigrationResult | Promise<MigrationResult>;
1429
+
1430
+ export interface AppliedMigrationReport {
1431
+ applied: string[];
1432
+ skipped: string[];
1433
+ shape: string;
1434
+ }
1435
+ export interface UpToDateMigrationReport {
1436
+ applied: [];
1437
+ skipped: string[];
1438
+ upToDate: true;
1439
+ }
1440
+ export interface DryRunMigrationReport {
1441
+ dryRun: true;
1442
+ pending: string[];
1443
+ statements: string[];
1444
+ /** Current rows by transform collection; null when its mapped relation does
1445
+ * not exist yet. These are not predictions after pending SQL runs. */
1446
+ counts: Record<string, number | null>;
1447
+ shadowValidated: boolean;
1448
+ }
1449
+ export type MigrationResult = AppliedMigrationReport | UpToDateMigrationReport | DryRunMigrationReport;
1385
1450
  /** Whether an assertion step is a per-document predicate (a FLWOR over
1386
1451
  * `$[*]` whose body reads only its binding), which the runner evaluates
1387
1452
  * per batch; anything else reads the collection whole. */
@@ -1396,20 +1461,33 @@ export interface MigrationStatusReport {
1396
1461
  drift: string | null;
1397
1462
  upToDate: boolean;
1398
1463
  }
1464
+ export interface MigrationStatusOptions {
1465
+ model?: unknown;
1466
+ physicalTarget?: PhysicalMigrationTarget;
1467
+ /** Reference driver required by borrowed model-only comparison. */
1468
+ shadowDriver?: Driver;
1469
+ registerFunctions?: (connection: unknown) => unknown;
1470
+ signal?: AbortSignal;
1471
+ deadline?: number;
1472
+ runtime?: Partial<Runtime>;
1473
+ }
1399
1474
  /** Report a database's migration state without touching it: the
1400
1475
  * history table is probed, never created. `model` enables the drift
1401
1476
  * comparison once the chain is fully applied. */
1402
1477
  export declare function migrationStatus(
1403
1478
  target: MigrationTarget,
1404
1479
  migrations: readonly unknown[],
1405
- options?: { model?: unknown;
1406
- registerFunctions?: (connection: unknown) => unknown;
1407
- signal?: AbortSignal; deadline?: number; runtime?: Partial<Runtime> },
1480
+ options?: MigrationStatusOptions,
1408
1481
  ): Promise<MigrationStatusReport>;
1482
+ export declare function migrationStatus(
1483
+ target: BorrowedMigrationTarget,
1484
+ migrations: readonly unknown[],
1485
+ options?: MigrationStatusOptions,
1486
+ ): MigrationStatusReport | Promise<MigrationStatusReport>;
1409
1487
  /** Create a model's whole physical shape on a connection. */
1410
1488
  export declare function createModelShape(connection: unknown, model: unknown): unknown;
1411
- /** The declared schema, normalized for shape-equality comparison. */
1412
- export declare function schemaShapeOf(connection: unknown):
1489
+ /** The declared schema, preserving physical column order unless explicitly relaxed. */
1490
+ export declare function schemaShapeOf(connection: unknown, options?: DeclaredSqlOptions):
1413
1491
  Promise<Array<{ type: string; name: string; owner: string; sql: string }>>
1414
1492
  | Array<{ type: string; name: string; owner: string; sql: string }>;
1415
1493
  /** Null when the database's shape equals a fresh build of the model. */
@@ -1473,7 +1551,7 @@ export interface DocumentMigrationOptions {
1473
1551
  * source is rewindable, so the steps run exactly as a Store runs them —
1474
1552
  * every step over the whole collection, in step order — which is what
1475
1553
  * makes the answer, and the refusal, identical to the Store's. A step
1476
- * that needs tables (`ddl`, `sql`, `rebuild`, `derive`) is refused
1554
+ * that needs tables (`ddl`, `sql`, `rebuild`, `derive`, `table`) is refused
1477
1555
  * (`JD0023`) before the first document is read. */
1478
1556
  export declare function migrateDocuments(
1479
1557
  collections: Record<string, readonly unknown[]>,
@@ -1522,8 +1600,13 @@ export declare function stepFailure(
1522
1600
 
1523
1601
  export declare function planCollection(name: string, collection: unknown, dialect: Dialect): unknown;
1524
1602
  export declare function compileIndexPath(expression: string, docPath: string): unknown;
1603
+ /** Conservative declaration comparison policy. Index, trigger and constraint order is always preserved. */
1604
+ export interface DeclaredSqlOptions {
1605
+ /** Preserve physical order by default; ignore only safe managed named-column order. */
1606
+ columnOrder?: 'preserve' | 'ignore';
1607
+ }
1525
1608
  export declare function normalizeDeclaredSql(sql: string): string;
1526
- export declare function comparableDeclaredSql(sql: string): string;
1609
+ export declare function comparableDeclaredSql(sql: string, options?: DeclaredSqlOptions): string;
1527
1610
  /** The comparison kind a declared schema type implies — what a column
1528
1611
  * over that member holds, and how its expression must read it. */
1529
1612
  export declare function columnKindFor(
@@ -2041,8 +2124,25 @@ export interface TrustedSyncSql {
2041
2124
  export declare function planInvariants(model: unknown, options: { dialect: Dialect }): {
2042
2125
  type: 'trigger'; name: string; owner: string; rule: string; sql: string;
2043
2126
  }[];
2044
- export declare function planPhysicalMigration(connection: unknown, fromModel: unknown, toModel: unknown,
2045
- options: { id: string; steps: readonly unknown[]; dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2046
- assertions?: readonly { sql: string; params?: readonly unknown[]; expected: readonly unknown[] }[] }): unknown;
2127
+ /** A reviewed preservation document. The supplied steps retain their types;
2128
+ * untyped saved steps remain unknown until the caller validates them. */
2129
+ export interface PhysicalMigrationDocument<Steps extends readonly unknown[] = readonly unknown[]> {
2130
+ readonly $migration: '0.1';
2131
+ readonly id: string;
2132
+ readonly from: string;
2133
+ readonly to: string;
2134
+ readonly steps: Steps;
2135
+ readonly physical: {
2136
+ readonly source: readonly SchemaObject[];
2137
+ readonly dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2138
+ readonly assertions: readonly { readonly sql: string; readonly params?: readonly unknown[]; readonly expected: readonly unknown[] }[];
2139
+ readonly target?: PhysicalMigrationTarget;
2140
+ };
2141
+ }
2142
+ /** Planning retains a synchronous connection's value boundary. */
2143
+ export declare function planPhysicalMigration<const Steps extends readonly unknown[]>(connection: unknown, fromModel: unknown, toModel: unknown,
2144
+ options: { id: string; steps: Steps; dispositions: Readonly<Record<string, 'preserve' | 'replace' | 'drop'>>;
2145
+ assertions?: readonly { sql: string; params?: readonly unknown[]; expected: readonly unknown[] }[];
2146
+ physicalTarget?: PhysicalMigrationTarget }): PhysicalMigrationDocument<Steps> | Promise<PhysicalMigrationDocument<Steps>>;
2047
2147
 
2048
- export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended } from './relational.js';
2148
+ export { sql, relational, planRelational, defineTable, planTable, planTableMigration, applyTableMigration, withForeignKeysSuspended, planSchemaChange, applySchemaChange } from './relational.js';
@@ -4,7 +4,7 @@ export type SqlInput = SqlValue | SqlExpression;
4
4
  export type SqlOperator = '=' | '<>' | '<' | '<=' | '>' | '>=' | 'IS' | 'IS NOT'
5
5
  | '+' | '-' | '*' | '/' | '%' | '||' | 'AND' | 'OR' | 'LIKE' | 'NOT LIKE' | 'GLOB';
6
6
  export type SqlFunction = 'coalesce' | 'nullif' | 'trim' | 'ltrim' | 'rtrim' | 'lower' | 'upper'
7
- | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid'
7
+ | 'length' | 'abs' | 'round' | 'typeof' | 'json_extract' | 'json_valid' | 'json_type'
8
8
  | 'count' | 'sum' | 'total' | 'avg' | 'min' | 'max'
9
9
  | 'date' | 'time' | 'datetime' | 'julianday' | 'unixepoch' | 'strftime';
10
10
  export type SqlType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB' | 'NUMERIC';
@@ -78,8 +78,13 @@ export interface TableColumn {
78
78
  readonly default?: SqlInput; readonly collation?: SqlCollation;
79
79
  readonly identity?: 'rowid' | 'autoincrement'; readonly check?: SqlInput;
80
80
  readonly generated?: SqlInput; readonly stored?: boolean;
81
+ readonly references?: ColumnReference;
81
82
  }
82
83
  export type ForeignKeyAction = 'cascade' | 'restrict' | 'no action' | 'set null' | 'set default';
84
+ export interface ColumnReference {
85
+ readonly table: string; readonly columns: readonly [string];
86
+ readonly onDelete?: ForeignKeyAction; readonly onUpdate?: ForeignKeyAction; readonly deferred?: boolean;
87
+ }
83
88
  export type TableConstraint = { readonly name?: string } & (
84
89
  { readonly kind: 'unique'; readonly columns: readonly string[] }
85
90
  | { readonly kind: 'check'; readonly expression: SqlInput }
@@ -111,4 +116,17 @@ export interface TableMigrationPlan {
111
116
  export declare function planTableMigration(connection: unknown, definition: TableDefinition, options: TableMigrationOptions): TableMigrationPlan;
112
117
  export declare function applyTableMigration(connection: unknown, plan: TableMigrationPlan): { changed: number };
113
118
  export declare function withForeignKeysSuspended<T>(connection: unknown, fn: () => T): T;
119
+ export type SchemaChange =
120
+ | { readonly op: 'addColumn'; readonly table: string; readonly column: TableColumn }
121
+ | { readonly op: 'dropIndex'; readonly name: string; readonly ifExists?: boolean }
122
+ | { readonly op: 'renameTable'; readonly table: string; readonly to: string }
123
+ | { readonly op: 'dropTable'; readonly table: string; readonly ifExists?: boolean };
124
+ export interface SchemaChangePlan {
125
+ readonly version: 1; readonly operation: SchemaChange; readonly sql: string;
126
+ readonly source: readonly unknown[]; readonly settings: readonly number[]; readonly checksum: string;
127
+ }
128
+ /** Main-schema snapshot; does not execute SQL or infer replay/disposition policy. */
129
+ export declare function planSchemaChange(connection: unknown, operation: SchemaChange): SchemaChangePlan;
130
+ /** Refuses stale source/settings under an immediate transaction. */
131
+ export declare function applySchemaChange(connection: unknown, plan: SchemaChangePlan): { changed: number };
114
132
  export { sqliteDialect } from './index.js';