@jarenjs/db 0.87.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.
package/ARCHITECTURE.md CHANGED
@@ -951,16 +951,32 @@ back so the retry plans the same statements again.
951
951
 
952
952
  ## The migration engine, relationally (`src/migrate.js`, `src/cli.js`)
953
953
 
954
- The strategy-table diff renders SELF-CONTAINED steps — additive
955
- columns, data steps, and one rebuild implementation following
956
- SQLite's documented twelve-step procedure with `foreign_key_check`
957
- inside the transaction. Shape EQUALITY (schemaShapeOf versus a fresh
958
- createModelShape build) is the acceptance criterion, asserted on the
959
- shadow before the real database is touched and again after. Probed
960
- and designed around: node:sqlite enables foreign keys BY DEFAULT (the
961
- pragma bracket is load-bearing), `jsonb()` PARSES its argument
962
- (column folds pass plain SQL values), and a table rename does not
963
- rename columns (join tables rename their endpoint keys explicitly).
954
+ The strategy-table diff renders managed hybrid-model steps. Physical
955
+ declaration changes remain an explicit planning-policy refusal; reviewed
956
+ `table` steps delegate to `table-migration.js` with their entire source,
957
+ checksum, storage, identity and object guards. `migrate.js` owns the one
958
+ history/receipt transaction for both structural and data steps.
959
+
960
+ `physical-transform.js` reuses entity codecs and the document-step kernel
961
+ for bounded column reads and changed-only writes. Each step can select a
962
+ historical model. Keys remain fixed; generated/default ownership and
963
+ unchanged raw storage survive. Text cursor bytes are checked against the
964
+ native binding before exposing their batch; unsupported round-trips refuse.
965
+
966
+ `migration-target.js` owns connection acquisition/cleanup and complete
967
+ physical target acceptance for apply, repeated startup and status.
968
+ Application-owned inventories include retained programs while unrelated
969
+ tables stay outside the selected scope. `schema-sql.js` is the single
970
+ literal-aware comparison owner: physical order is strict, and safe managed
971
+ named-column comparison opts into a narrow relaxation. Exact source
972
+ snapshots and checksums never use relaxed comparison.
973
+
974
+ `foreign-key-scope.js` brackets FK/legacy settings around the driver's
975
+ IMMEDIATE transaction. Nested work shares savepoints. Owned handles close
976
+ even after initialization failure; borrowed synchronous work retains its
977
+ value boundary. `shadowFixture` initializes a disposable historical schema
978
+ before replay calls the same `migrate` and receipt executor, with recursive
979
+ replay disabled. Every selected target is accepted before its receipt.
964
980
 
965
981
  ## One cross-runtime seam worth remembering
966
982
 
@@ -1176,8 +1192,9 @@ flattening join this native subset; strict binding failures stop before a scan.
1176
1192
  read classifier's tokenizer and the driver's scope owner. Writes invalidate all
1177
1193
  clean tracked entities; pending edits and incomplete capture populations refuse.
1178
1194
  `invariants.js` uses the shared Query compiler for store rules; the dialect lowers
1179
- a bounded database subset into ordered trigger bodies. `migrate.js` reuses its
1180
- existing rebuild/receipt transaction and verifies preservation before publication.
1195
+ a bounded database subset into ordered trigger bodies. `migrate.js` composes
1196
+ guarded table plans and codec-aware transforms in its existing receipt
1197
+ transaction, checking preservation and complete selected targets before publication.
1181
1198
  The backup publisher uses Node online backup and Bun disk-backed VACUUM INTO.
1182
1199
  The standalone snapshot helper shares destination reservation and cleanup across
1183
1200
  both hosts, including transaction failures.
package/README.md CHANGED
@@ -1432,10 +1432,18 @@ and read-only views have explicit contracts in [MODEL-FORMAT](docs/MODEL-FORMAT.
1432
1432
  Inside `store.transaction`, `tx.sql.prepare(text, { access: 'read' | 'write' })`
1433
1433
  shares the entity/outbox connection and savepoint owner. Statements expire with
1434
1434
  the scope. [The client recipe](../linq/docs/DB-CLIENT.md#trusted-sql-during-adoption)
1435
- documents trust, invalidation and synchronous execution. Schema changes use
1436
- `planPhysicalMigration` and `migrate`; [preservation and forward recovery](docs/MIGRATION-FORMAT.md#existing-physical-files-and-forward-recovery)
1437
- require explicit dispositions and assertions. `planInvariants` supplies declared
1438
- SQLite constraint/audit triggers for installation through that migration boundary.
1435
+ documents trust, invalidation and synchronous execution. Schema changes compose
1436
+ a complete saved `planTableMigration` artifact as a guarded `table` step through
1437
+ `planPhysicalMigration` and `migrate`. Optional transforms/assertions carry their
1438
+ historical `model`; a reviewed `physicalTarget` checks complete owned objects,
1439
+ including on repeated startup. A borrowed `{ connection }` stays open and can
1440
+ finish synchronously with `shadow: false`; an owned `{ driver, path? }` returns
1441
+ a Promise and closes acquired resources on every path. Populated `shadowFixture`
1442
+ replay uses the same guarded executor and history owner. Start with the
1443
+ [runnable physical lifecycle](docs/MIGRATION-FORMAT.md#runnable-physical-lifecycle).
1444
+ Changed physical model diffs remain a specific `JD0021` policy refusal;
1445
+ structural changes are explicitly reviewed. `planInvariants` supplies declared
1446
+ SQLite constraint/audit triggers for installation through that boundary.
1439
1447
 
1440
1448
  Native column reads and bounded mutation documents are specified in [NATIVE-PLANS](docs/NATIVE-PLANS.md), including SQL census coverage, resource accounting and refusals.
1441
1449
 
@@ -46,11 +46,14 @@ shape change is a **transformation of values**, not a table rebuild.
46
46
  compiled JSLT stylesheet, in batches, inside the migration's
47
47
  transaction. The empty stylesheet (`[]`) is the identity transform.
48
48
  Over an ENTITY table the stylesheet sees the whole row — the mapped
49
- columns merged into the document under the TARGET model's mapping
50
- and what it returns is split back into columns and document by that
51
- mapping; the key member is kept from the row (a stylesheet that
52
- omits it loses nothing) and a stylesheet that changes it is
53
- `JD0023`. A step carrying `"draft": true` is a planner placeholder
49
+ columns merged into the document under `step.model`, when supplied,
50
+ or the target model otherwise. A hybrid row is split back into columns
51
+ and document by that mapping. A column-only physical entity is read
52
+ and written through its declared codecs, without a `doc` column.
53
+ Key members omitted by the stylesheet are retained; a changed key is
54
+ `JD0023`. Physical transforms assign only changed writable columns,
55
+ preserve omitted database-default/generated values, and refuse changed
56
+ generated fields, read-only views and unknown output fields. A step carrying `"draft": true` is a planner placeholder
54
57
  and MUST refuse to run (`JD0021`) until the author fills it in.
55
58
  - `kind: "query"` is an assertion: the query runs over the
56
59
  collection's documents and MUST answer an empty sequence (`expect:
@@ -68,6 +71,15 @@ shape change is a **transformation of values**, not a table rebuild.
68
71
  dry run always prints it with its note.
69
72
  - `kind: "rebuild"` is the entity restructure of §10, self-contained:
70
73
  the `CREATE` of the new shape, the copy and the index DDL.
74
+ - `kind: "table"` carries `{ plan }`, the COMPLETE saved artifact from
75
+ `planTableMigration`. It delegates to the guarded table executor,
76
+ retaining source/checksum, row/storage, identity and object checks.
77
+ `statements` and `finish` are review output; flattening them into DDL
78
+ steps loses those guards and is not an equivalent migration.
79
+ - `jslt` and `query` may carry an immutable `$model` 0.1 `model` describing
80
+ the layout at that step, including names absent from the final model.
81
+ An assertion before structural DDL can use the old model, and one after
82
+ it can use the new model. The selected mapping is verified before reads.
71
83
  - Steps are ordered, and the order is the contract.
72
84
 
73
85
  ### 2.1 Derived spatial columns and the backfill
@@ -140,6 +152,12 @@ recorded repair. The stored-column mapping does not need this repair.
140
152
 
141
153
  ### Model differences
142
154
 
155
+ `planModelMigration` returns an empty migration for identical physical
156
+ models. A changed physical declaration remains a specific `JD0021` policy
157
+ refusal: use `planTableMigration` against the live schema and compose its
158
+ saved artifact through `planPhysicalMigration`. The model diff does not
159
+ infer application-owned DDL, key rewrites or business backfills.
160
+
143
161
  `planMigration(fromModel, toModel, { dialect, id, derived })` produces
144
162
  `{ migration, report }` by diffing the two models' PHYSICAL plans. The
145
163
  from-model is the previous model — the previous model FILE, or, under
@@ -189,25 +207,43 @@ so the previous shape lives beside the code, where a diff can read it.
189
207
 
190
208
  ## 4. The shadow database
191
209
 
192
- Before the real store is touched, the WHOLE chain — the baseline
193
- shape, every applied migration, every pending migration replays on a
194
- shadow database (`:memory:` by default; free with SQLite, no server).
195
- The shadow proves **structure**: every DDL statement runs, every
196
- stylesheet and assertion compiles and executes, and the end shape is
197
- verified against the target model. A failure there leaves the real
198
- store untouched.
199
-
200
- The shadow runs over an empty data set; the real-data facts (the
201
- widening check, key consistency, the assertions over real rows) run on
202
- the real store inside its transaction. Batched transforms and validation
203
- include every row identity, including negative integer primary keys;
204
- target-schema validation visits every declared entity, even after an
205
- empty entity table.
206
- The shadow registers the same functions as the real run:
207
- `migrate(…, { registerFunctions })` runs on
208
- the shadow, the real and the reference connections before any DDL
209
- (§10), so a hand-created index over a registered deterministic
210
- function neither fails the shadow nor is silently dropped by it.
210
+ When pending work exists and `shadow` is not `false`, the whole chain
211
+ replays on a disposable shadow before real migration writes. The default
212
+ initializer builds the baseline model into an empty database; a supplied
213
+ `shadowFixture(connection)` instead creates the historical schema and
214
+ fixture rows. Physical preservation plans require that initializer or an
215
+ explicit `shadow: false`, because a query mapping cannot recreate all
216
+ application-owned programs.
217
+
218
+ Replay calls the SAME `migrate` executor with the shadow connection borrowed
219
+ and recursive replay disabled. Guarded table steps, per-step mappings,
220
+ preservation checks, target acceptance and history receipts all run there.
221
+ `shadowDriver` defaults to the owned target's driver; borrowed targets need
222
+ an explicit independent driver. `shadowPath` defaults to `':memory:'` and
223
+ must name a disposable database independent of the primary. The acquired
224
+ shadow closes after success, initialization failure or replay failure.
225
+
226
+ Before shadow registration, fixture callbacks or replay, Node and Bun compare
227
+ the opened files by device and inode, rejecting relative-path, dot-path, symlink
228
+ and hardlink aliases of the primary, including borrowed primary connections.
229
+ Separate private `:memory:` connections are allowed. An injected SQLite driver's
230
+ optional `databaseIdentity(connection)` hook can provide the same guarantee;
231
+ without it, the runner compares SQLite's canonical main filenames. Such a custom
232
+ driver owns alias detection beyond that filename comparison. An opener returning
233
+ the actual primary handle is refused without closing it.
234
+
235
+ Fixture initialization is a callback outside the saved migration artifact.
236
+ Use synthetic or appropriately isolated fixtures; do not put application
237
+ row snapshots into a shared plan. Empty default replay proves structure;
238
+ populated replay also exercises its fixture facts. Real-data validation
239
+ and assertions still run inside the primary migration transaction.
240
+ Batched work includes negative integer keys and visits every declared
241
+ entity, even after an empty entity table.
242
+
243
+ `registerFunctions` runs on the primary, shadow and independent reference
244
+ connections before their DDL. No replay or fixture initialization is needed
245
+ when the full chain is already applied; a supplied complete target is still
246
+ checked at repeated startup.
211
247
 
212
248
  ## 5. History and checksums
213
249
 
@@ -227,7 +263,23 @@ hash of the `baseline` model when no migration has run.
227
263
 
228
264
  ## 6. Running and batching
229
265
 
230
- `migrate({ driver, path }, migrations, options)`:
266
+ `migrate(target, migrations, options)` accepts two distinct ownership forms:
267
+
268
+ | Target | Ownership and return boundary |
269
+ |---|---|
270
+ | `{ driver, path?, busyTimeout? }` | Opens and closes its own connection; returns a Promise. Acquired resources close even if function registration or initialization fails. |
271
+ | `{ connection }` | Borrows an existing driver connection and leaves it open on success or failure; returns a value or Promise. Synchronous work with `shadow: false` stays synchronous when its connection and hooks do. |
272
+
273
+ A borrowed target cannot also carry `driver`, `path` or `busyTimeout`.
274
+ `migrationStatus` uses the same ownership forms. Borrowed model-only status
275
+ comparison needs `shadowDriver`; a complete `physicalTarget` needs no
276
+ fresh reference database. For a nested rebuild, enter
277
+ `withForeignKeysSuspended(connection, callback)` before the migration so
278
+ the driver can bracket FK settings outside the transaction and use nested
279
+ savepoints inside it. Each acquired connection has one cleanup owner;
280
+ if the operation and cleanup both fail, both errors are retained.
281
+
282
+ The run options include:
231
283
 
232
284
  - `options.baseline` (REQUIRED) — the model the store was FIRST
233
285
  created with: the chain's anchor and the shadow's starting shape.
@@ -235,8 +287,18 @@ hash of the `baseline` model when no migration has run.
235
287
  last pending migration's `to` MUST equal its shape hash (`JD0020`
236
288
  otherwise), the physical end shape is verified, and the real-data
237
289
  validation of §3 runs.
238
- - `dryRun: true` prints every statement and the affected document
239
- counts, validates the chain on the shadow, and writes NOTHING — not
290
+ - `physicalTarget: { objects, tables? }` supplies the complete reviewed
291
+ application schema, using `readSchema(reference, { tables }).objects`.
292
+ It overrides the last plan's saved `physical.target` for final acceptance
293
+ and is also checked when there are no pending migrations. Each pending
294
+ plan's selected target is checked before its receipt is inserted.
295
+ - `shadowFixture`, `shadowDriver` and `shadowPath` configure the disposable
296
+ replay described in §4.
297
+ - `dryRun: true` prints every statement and current row counts by transform
298
+ collection. A count is `null` when that step's mapped table/view does not
299
+ exist yet. Counts describe the current database, not predicted affected
300
+ rows after pending SQL runs. It validates the chain on the shadow unless `shadow: false`, and
301
+ reports whether shadow validation ran. It writes NOTHING — not
240
302
  even the history table: it PROBES for one and reads an absent one as
241
303
  an empty history, so a dry run may be pointed at a production
242
304
  database and leave its file byte-identical. The API default is to
@@ -454,16 +516,16 @@ literally, OUTSIDE the transaction (inside one the pragma is a no-op):
454
516
  table could not even be dropped, so a migration holding a rebuild
455
517
  step turns enforcement off before `BEGIN IMMEDIATE` and back on after
456
518
  it settles, and `foreign_key_check` inside the transaction provides
457
- the guarantee the bracket suspended; (2) triggers and views are not re-created because this
458
- store creates none a hand-added trigger is outside the model and
459
- outside the diff, which drift (§12) will name.
519
+ the guarantee the bracket suspended; (2) this hybrid-model rebuild does
520
+ not recreate arbitrary application triggers or views. Existing physical
521
+ programs use reviewed `table` steps and preservation dispositions instead.
460
522
 
461
- **Shape equality is the acceptance criterion.** After a rebuild —
462
- after ANY relational migration the database's declared schema
463
- (`schemaShapeOf`) must equal what a fresh `createModelShape(toModel)`
464
- produces, indexes, foreign keys and constraints included. The shadow
465
- asserts it before the real database is touched, and the real run
466
- asserts it again after the last migration.
523
+ **Shape equality is the acceptance criterion.** A managed hybrid-model
524
+ migration compares the database with a fresh `createModelShape(toModel)`,
525
+ including indexes, foreign keys and constraints. Physical mappings verify
526
+ their declared columns and invariants; complete application schema acceptance
527
+ requires a reviewed `physicalTarget`, including retained programs. The
528
+ shadow and primary use the same selected acceptance rule before publication.
467
529
 
468
530
  **UDF-expression indexes.** An index over a registered deterministic
469
531
  function is invisible to any connection that has not registered the
@@ -587,13 +649,31 @@ jaren-db documents --migrations <dir> --in <file|-> (--out <file|-> | --in-place
587
649
 
588
650
  ## 12. Drift
589
651
 
590
- Drift is the database not matching what its history says it should
591
- be: someone changed it by hand. `status`/`check` detect it by
592
- verifying the current model's physical shape against the actual
593
- database (`schemaShapeOf` against `createModelShape`, when the chain
594
- is fully applied) a hand-added index, a dropped column or a foreign
595
- key edited outside a migration is named early, which is the
596
- difference between a puzzled afternoon and a five-minute fix.
652
+ Drift is a database that disagrees with its expected schema. Once the
653
+ chain is fully applied, `migrationStatus` compares the explicit
654
+ `options.physicalTarget`, or the last migration's saved `physical.target`.
655
+ Without either target, `options.model` enables comparison with a fresh
656
+ model build. With neither a target nor a model it reports history only.
657
+ An already-applied `migrate` call also checks a supplied complete target.
658
+
659
+ `comparableDeclaredSql` is the single conservative declaration comparator;
660
+ `normalizeDeclaredSql` and `schemaShapeOf` delegate to it. Ordinary SQLite
661
+ token whitespace, comments and a CREATE-prefix `IF NOT EXISTS` are
662
+ formatting. String contents, quoted identifiers and escapes retain their
663
+ bytes. Quote style and SQL/identifier case are not silently canonicalized.
664
+ Physical column order, constraint order, index term order/collations/
665
+ predicates and trigger programs remain significant. Malformed or unsupported
666
+ lexical input refuses with `TypeError`; this is not a general SQL-equivalence
667
+ proof.
668
+
669
+ Managed named-column comparison explicitly selects
670
+ `{ columnOrder: 'ignore' }`. It may reorder ordinary column declarations,
671
+ but retains table constraints and falls back to strict order for unfamiliar
672
+ forms or inline `DEFAULT`, `CHECK`, `UNIQUE`, `REFERENCES`, `COLLATE` and
673
+ `CONFLICT` clauses. Even static defaults retain order; expression purity is
674
+ not inferred. Direct public comparison and
675
+ complete physical targets default to `{ columnOrder: 'preserve' }`.
676
+ Exact saved source snapshots and checksums never use this relaxed policy.
597
677
 
598
678
  Down migrations REMAIN a non-goal (§7's reasoning is unchanged): a
599
679
  down migration is a data-loss generator wearing a seatbelt; recovery
@@ -601,34 +681,143 @@ is a backup restored plus the forward chain.
601
681
 
602
682
  ## Existing physical files and forward recovery
603
683
 
604
- `planPhysicalMigration(connection, fromModel, toModel, options)` records the
605
- source schema and explicit DDL/SQL/rebuild steps as an ordinary migration document.
606
- `options` supplies an `id`, `steps`, a disposition for every source `type:name`
607
- (`preserve`, `replace`, or `drop`), and optional `{ sql, params, expected }`
608
- preservation assertions. Assertions are SELECTs evaluated before and after the
609
- steps. They should cover committed identities, exact BLOB hex and application
610
- history facts. Unknown objects cannot disappear without a declared disposition.
611
- Automatic hybrid model diffing refuses column layouts; an explicit plan is required.
612
-
613
- Apply through `migrate(target, [plan], { baseline, model, shadow: false })`.
614
- A physical plan must be qualified on an explicit backup and fresh-target fixture;
615
- an empty model-generated shadow cannot recreate the original file's application
616
- programs. The runner uses its existing immediate transaction, ordered steps,
617
- checksummed receipt and FK checks. It checks the source schema before destructive
618
- steps and preserved objects and assertions before publication. Target mapped
619
- columns and declared invariant triggers are verified. A changed source is
620
- `JD0020`, a lost object/fact is `JD0023`, and an edited applied receipt is `JD0022`.
621
-
622
- Migration history is created inside the applying transaction only when needed.
623
- An identical second run executes no DDL or DML. Failed steps and failed commits
624
- roll back; after a process kill SQLite recovery leaves the source or the committed
625
- target. Re-running resumes from committed receipts. Forward repair plans start
626
- from the newest file, including later application edits; restoring an older
627
- backup does not qualify as forward repair.
684
+ `planPhysicalMigration(connection, fromModel, toModel, options)` returns
685
+ a migration document or Promise, preserving the supplied step types in its
686
+ `PhysicalMigrationDocument<Steps>` declaration. Its `options` contain `id`,
687
+ ordered `steps`, and a disposition for EVERY observed application source
688
+ `type:name`: `preserve`, `replace` or `drop`. Steps may be `ddl`, `sql`,
689
+ `rebuild`, guarded `table`, `jslt` or `query`. Engine-owned metadata is
690
+ excluded from source snapshots; arbitrary user objects are not.
691
+
692
+ Optional `{ sql, params, expected }` preservation assertions are SELECTs
693
+ checked before and after the steps. Use them for portable committed facts
694
+ that must survive. A changed exact source is `JD0020`, a lost preserved
695
+ object/fact or mismatched target is `JD0023`, and an edited applied
696
+ migration is `JD0022`. SQL steps cannot take transaction or connection
697
+ ownership; each step carries one statement or one complete trigger program.
698
+
699
+ `options.physicalTarget` is copied into `physical.target`. Its `objects`
700
+ must be the complete declarations for its owned scope: tables/views and
701
+ their indexes/triggers, each with `{ type, name, owner, sql }` and SQL text.
702
+ `tables` defaults to object owners. Include an absent table name explicitly
703
+ when the plan requires a dropped table to remain absent. Extra objects
704
+ attached to owned tables are drift; unrelated tables outside the scope
705
+ are allowed. Keep the inventory scoped with `readSchema(reference,
706
+ { tables: [...] })`; never silently discard unknown objects within it.
707
+ A physical model's column mapping alone is not this complete target.
708
+
709
+ ### Runnable physical lifecycle
710
+
711
+ Run this ES module in a project with `@jarenjs/db` and `@jarenjs/linq`
712
+ installed, using Node 24 or Bun. All databases are disposable. The example
713
+ uses aliased physical columns, a historical transform before a guarded
714
+ rebuild, a target fixture, populated replay and checked repeat startup.
715
+ The saved document contains schema and instructions; the synthetic rows
716
+ remain in the initializer. In an application, persist and review that
717
+ document before applying it to the intended connection.
718
+
719
+ ```js
720
+ import assert from 'node:assert/strict';
721
+ import { defineModel, object, integer, string } from '@jarenjs/linq/model';
722
+ import { defineMigration, fromPlanned } from '@jarenjs/linq/migration';
723
+ import { planTable, planTableMigration, applyTableMigration, planPhysicalMigration,
724
+ readSchema, migrate, migrationStatus } from '@jarenjs/db';
725
+
726
+ const driver = process.versions.bun
727
+ ? (await import('@jarenjs/db/bun')).bunDriver()
728
+ : (await import('@jarenjs/db/node')).nodeDriver();
729
+ const columns = {
730
+ id: { name: 'item_id', codec: 'integer', null: 'reject' },
731
+ value: { name: 'label', codec: 'text', null: 'reject' },
732
+ };
733
+ const before = defineModel({ entities: {
734
+ Item: object({ id: integer().key(), value: string() })
735
+ .physical({ table: 'items', columns }),
736
+ } });
737
+ const after = defineModel({ entities: {
738
+ Item: object({ id: integer().key(), value: string(), revision: integer() })
739
+ .physical({ table: 'items', columns: { ...columns,
740
+ revision: { name: 'revision', codec: 'integer', null: 'reject', default: 'database' },
741
+ } }),
742
+ } });
743
+ const oldTable = { name: 'items', primaryKey: ['item_id'], columns: [
744
+ { name: 'item_id', type: 'INTEGER', identity: 'autoincrement', nullable: false },
745
+ { name: 'label', type: 'TEXT', nullable: false },
746
+ ] };
747
+ const newTable = { ...oldTable, columns: [...oldTable.columns,
748
+ { name: 'revision', type: 'INTEGER', default: 1, nullable: false },
749
+ ] };
750
+
751
+ // Disposable synthetic data belongs to the fixture, outside the saved plan.
752
+ function initializeHistorical(connection) {
753
+ for (const sql of planTable(oldTable).createSql) connection.exec(sql);
754
+ connection.exec("INSERT INTO items VALUES(1,'example'); INSERT INTO items VALUES(99,'retired'); DELETE FROM items WHERE item_id=99");
755
+ connection.exec('CREATE TABLE unrelated(note TEXT)');
756
+ }
757
+
758
+ const connection = await driver.open(':memory:');
759
+ try {
760
+ initializeHistorical(connection);
761
+ const tablePlan = planTableMigration(connection, newTable, {
762
+ id: 'items-revision', allowRebuild: true,
763
+ });
764
+ // Review and save the complete artifact, including its guards and checksum.
765
+ const savedTablePlan = JSON.parse(JSON.stringify(tablePlan));
766
+ const reference = await driver.open(':memory:');
767
+ let physicalTarget;
768
+ try {
769
+ initializeHistorical(reference);
770
+ applyTableMigration(reference, savedTablePlan);
771
+ physicalTarget = {
772
+ objects: (await readSchema(reference, { tables: ['items'] })).objects,
773
+ tables: ['items', 'retired_items'], // retired_items must remain absent
774
+ };
775
+ }
776
+ finally { await reference.close(); }
777
+
778
+ const steps = defineMigration({ id: 'items-revision', from: before, to: after })
779
+ .assert('Item', (row) => row.value.isEmpty(), { model: before })
780
+ .transform('Item', (row) => ({ id: row.id, value: row.value.upper() }), { model: before })
781
+ .step({ kind: 'table', plan: savedTablePlan })
782
+ .assert('Item', (row) => row.revision.lt(1), { model: after }).document.steps;
783
+ const inventory = await readSchema(connection);
784
+ const planned = await planPhysicalMigration(connection, before, after, {
785
+ id: 'items-revision', steps, physicalTarget,
786
+ dispositions: Object.fromEntries(inventory.objects.map((item) =>
787
+ [`${item.type}:${item.name}`, item.name === 'items' ? 'replace' : 'preserve'])),
788
+ });
789
+ const migration = fromPlanned(planned, { from: before, to: after }).document;
790
+ const savedMigration = JSON.parse(JSON.stringify(migration));
791
+ const options = { baseline: before, model: after,
792
+ shadowDriver: driver, shadowFixture: initializeHistorical };
793
+ assert.deepEqual((await migrate({ connection }, [savedMigration], options)).applied, ['items-revision']);
794
+ assert.deepEqual({ ...connection.prepare('SELECT * FROM items').get([]) },
795
+ { item_id: 1, label: 'EXAMPLE', revision: 1 });
796
+ connection.exec('CREATE TABLE later_unrelated(note TEXT)');
797
+ assert.deepEqual((await migrate({ connection }, [savedMigration], options)).applied, []);
798
+ assert.equal((await migrationStatus({ connection }, [savedMigration])).upToDate, true);
799
+ assert.equal(Number(connection.prepare("INSERT INTO items(label) VALUES('later')").run([]).lastInsertRowid), 100);
800
+ }
801
+ finally { await connection.close(); }
802
+ ```
803
+
804
+ The table executor preserves row counts, unchanged values/storage classes,
805
+ keys/rowids, retained programs and AUTOINCREMENT high-water marks. The
806
+ migration executor owns its existing IMMEDIATE transaction and receipt;
807
+ it checks the selected target before recording that receipt. A successful
808
+ repeat executes no migration DDL/DML and returns `upToDate: true`; an
809
+ explicit target still detects drift. The borrowed handle stays open.
810
+
811
+ Failures in steps, target checks, receipt writes or COMMIT roll back the
812
+ active migration. SQLite process-recovery tests establish source-or-committed-
813
+ target recovery after interruption, not power-loss durability. Earlier
814
+ committed migrations remain committed. Re-running resumes from receipts;
815
+ forward repair plans start from the newest file, including later application
816
+ edits, rather than replacing it with an older backup.
628
817
 
629
818
  `backupTo()` publishes a sibling temporary only after a complete snapshot.
630
- Node uses online backup. Bun uses its native serialized SQLite snapshot under
819
+ Node uses online backup. Bun writes a disk-backed `VACUUM INTO` snapshot under
631
820
  the store gate, then flushes and atomically renames through the same publisher.
632
- Bun holds a full database image in memory and cannot offer page-granular copy
633
- cancellation. Both snapshots include committed WAL; interruption before rename
821
+ Bun does not build a full database image in JavaScript memory; it cannot offer
822
+ page-granular copy cancellation. Both snapshots include committed WAL; interruption before rename
634
823
  leaves the previous destination valid, while a leftover temporary is not published.
@@ -353,8 +353,8 @@ a control character, on SQLite) is `JD0004`.
353
353
  **Opening an existing database verifies, never alters.** If a declared
354
354
  collection's table already exists it MUST match what the model would
355
355
  create; any disagreement is `JD0002` naming the first difference.
356
- Reshaping a live database is the migration story — a later capability —
357
- and `openStore` MUST NOT attempt it.
356
+ Reshaping a live database requires an explicit migration;
357
+ `openStore` MUST NOT attempt it.
358
358
 
359
359
  "Match" means every physical property that decides behaviour, not just
360
360
  the names and types:
@@ -376,10 +376,14 @@ the names and types:
376
376
  - an index the database has and the model does not declare is also
377
377
  drift: it changes deletion semantics and the plans the optimizer picks.
378
378
 
379
- Physical column ORDER is deliberately **not** drift. SQLite's
380
- `ALTER TABLE … ADD COLUMN` can only append, so a migrated table and a
381
- freshly built one legitimately disagree there, and this store never reads
382
- a column positionally.
379
+ Managed named-column comparison explicitly permits safe column reordering:
380
+ SQLite's `ALTER TABLE … ADD COLUMN` appends, and these consumers address
381
+ columns by name. The comparison retains constraint order and stays strict
382
+ for inline defaults, other order-sensitive clauses or unfamiliar forms. Complete reviewed
383
+ physical targets and direct `schemaShapeOf`/`comparableDeclaredSql` calls
384
+ preserve physical column order by default. Literal and quoted-identifier
385
+ bytes always remain significant; whitespace inside them is never formatting.
386
+ See [the comparison policy](MIGRATION-FORMAT.md#12-drift).
383
387
 
384
388
  ### 3.1 Derived columns, and the capability branch
385
389
 
@@ -2374,6 +2378,34 @@ Capture/live/replication for adopted application triggers is not qualified and
2374
2378
  is refused, rather than advertised as a complete change stream. PostgreSQL
2375
2379
  column adoption is not qualified; physical inventory remains available.
2376
2380
 
2381
+ ### Physical migration rows and acceptance
2382
+
2383
+ Physical mappings can participate in migration `jslt` and `query` steps.
2384
+ The step's optional `model` selects its current layout; absent it, the
2385
+ final model is used. The runner verifies that layout before reading mapped
2386
+ columns. Transforms keep scalar/composite keys fixed and assign only changed
2387
+ writable columns, preserving unchanged raw text/JSON/BLOB storage and avoiding
2388
+ unrelated `UPDATE OF` triggers. Omitted database-default/generated values
2389
+ remain owned by the database. Changed generated fields, read-only writes and
2390
+ unknown output fields refuse.
2391
+
2392
+ Migration table scans use bounded pages with text, integer or bigint key
2393
+ codecs; view assertions use bounded offset pages, and views remain read-only.
2394
+ This internal migration cursor does not enable the public physical `page`/
2395
+ `after` APIs. Text keys must round-trip through the database encoding and
2396
+ native binding. Malformed text refuses `JD0021` before its batch is exposed,
2397
+ rolling back the active migration. Node supports leading-BOM text keys.
2398
+ Bun 1.4 strips a leading BOM when binding a cursor parameter; the migration
2399
+ reader detects that limitation and refuses those keys with `JD0021`.
2400
+ That is a binding limitation, not a claim that ordinary reads corrupt them.
2401
+
2402
+ A mapping covers declared fields and invariants, not every schema object
2403
+ an application owns. For complete startup acceptance, supply a reviewed
2404
+ `physicalTarget` inventory of the intended owned tables and their programs.
2405
+ Changed physical declarations are a `JD0021` policy refusal in
2406
+ `planModelMigration`; use an explicit guarded table plan and the existing
2407
+ migration lifecycle. See [the runnable lifecycle](MIGRATION-FORMAT.md#runnable-physical-lifecycle).
2408
+
2377
2409
  ## 13. Persistence invariants
2378
2410
 
2379
2411
  An entity may declare `invariants`: each has `name`, `on` (insert/update/delete),
@@ -135,8 +135,10 @@ Entity `.physical(...)` metadata can also supply column `type`, `defaultValue`,
135
135
  `planEntity(...).createSql` now emits explicit table DDL for complete writable
136
136
  declarations; views and incomplete generated/default definitions remain adoption
137
137
  metadata. `openStore` still verifies physical tables without creating them.
138
- Identical physical models produce an empty `planModelMigration`; changed
139
- physical tables use the live-schema `planTableMigration` API.
138
+ Identical physical models produce an empty `planModelMigration`. Changed
139
+ physical declarations remain a specific `JD0021` policy refusal; use the
140
+ live-schema `planTableMigration` API and review the resulting structural plan.
141
+ The model diff does not infer application-owned DDL or business backfills.
140
142
 
141
143
  ## Guarded upgrades
142
144
 
@@ -155,6 +157,8 @@ unchanged values/storage classes, drops/renames atomically, restores indexes and
155
157
  triggers, and checks foreign keys and the target schema. Primary-key columns,
156
158
  unshadowed hidden rowids, raw text, bytes and AUTOINCREMENT high-water marks are
157
159
  preserved. A key change, rowid-ownership change or ambiguous rowid alias refuses.
160
+ This includes converting an `INTEGER PRIMARY KEY DESC` with an independent
161
+ hidden rowid into an ordinary INTEGER primary key that owns the rowid.
158
162
  No migration history table or model adoption is needed.
159
163
 
160
164
  Rebuilds require SQLite's foreign-key transition outside a transaction. For a
@@ -167,6 +171,31 @@ refuses before DDL. Node/Bun regressions cover populated history and references,
167
171
  failed copies, repeat reopening, nested rollback, and process death after DROP
168
172
  with WAL recovery. These tests do not establish power-loss durability.
169
173
 
174
+ ### Composing a guarded plan with migration history
175
+
176
+ Pass the complete saved table artifact as `{ kind: 'table', plan }` in
177
+ `planPhysicalMigration` or `.step({ kind: 'table', plan })` in the migration
178
+ pen. It retains `version`, `id`, `table`, `source`, `after`, `rebuild`,
179
+ `temporary`, `unchanged`, `statements`, `finish` and `checksum`; incomplete
180
+ plans refuse. Do not flatten its SQL arrays into separate DDL steps: the
181
+ guarded executor also preserves storage classes and allocated identities.
182
+
183
+ `migrate` supplies the existing history/receipt transaction around that
184
+ executor. It admits the writer with IMMEDIATE semantics before source reads,
185
+ uses savepoints for nested work and restores FK/legacy settings exactly.
186
+ `{ connection }` borrows the same driver handle; `{ driver, path? }` owns its
187
+ resource. Both compose with optional historical transforms/assertions and a
188
+ complete `physicalTarget` copied from a disposable target fixture. With
189
+ `shadowFixture`, replay uses the same migration and history executor.
190
+ The [runnable example](MIGRATION-FORMAT.md#runnable-physical-lifecycle) proves
191
+ this composition and a checked second run.
192
+
193
+ Complete physical targets retain physical column and constraint order,
194
+ quoted text, index predicates/collations and trigger programs. Only token
195
+ whitespace/comments and CREATE-prefix `IF NOT EXISTS` are formatting;
196
+ quote styles and case are not inferred equivalent. The separate exact
197
+ source snapshot/checksum remains the stale-plan guard.
198
+
170
199
  ## Additive and object operations
171
200
 
172
201
  `planSchemaChange(connection, operation)` returns a reviewable
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/db",
3
3
  "private": false,
4
- "version": "0.87.0",
4
+ "version": "0.89.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./types/index.d.ts",
@@ -108,9 +108,9 @@
108
108
  "prepack": "npm run build:types"
109
109
  },
110
110
  "dependencies": {
111
- "@jarenjs/core": "^0.87.0",
112
- "@jarenjs/json": "^0.87.0",
113
- "@jarenjs/validate": "^0.87.0"
111
+ "@jarenjs/core": "^0.89.0",
112
+ "@jarenjs/json": "^0.89.0",
113
+ "@jarenjs/validate": "^0.89.0"
114
114
  },
115
115
  "bin": {
116
116
  "jaren-db": "./src/cli.js"