@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 +29 -12
- package/README.md +12 -4
- package/docs/MIGRATION-FORMAT.md +259 -70
- package/docs/MODEL-FORMAT.md +38 -6
- package/docs/SQLITE-RELATIONAL.md +31 -2
- package/package.json +4 -4
- package/schemas/jaren-migration.draft-07.schema.json +169 -5
- package/schemas/jaren-migration.schema.json +164 -0
- package/src/ddl.js +8 -86
- package/src/dialects/sqlite.js +1 -0
- package/src/document-steps.js +23 -5
- package/src/drivers/bun.js +13 -3
- package/src/drivers/file-identity.js +14 -0
- package/src/drivers/node.js +2 -0
- package/src/foreign-key-scope.js +50 -0
- package/src/migrate.js +291 -497
- package/src/migration-target.js +104 -0
- package/src/physical-transform.js +207 -0
- package/src/schema-sql.js +184 -0
- package/src/table-migration.js +17 -17
- package/types/index.d.ts +113 -13
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
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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`
|
|
1180
|
-
|
|
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
|
|
1436
|
-
`
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
|
package/docs/MIGRATION-FORMAT.md
CHANGED
|
@@ -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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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(
|
|
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
|
-
- `
|
|
239
|
-
|
|
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)
|
|
458
|
-
|
|
459
|
-
|
|
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.**
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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)`
|
|
605
|
-
|
|
606
|
-
`
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
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
|
|
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.
|
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -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
|
|
357
|
-
|
|
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
|
-
|
|
380
|
-
`ALTER TABLE … ADD COLUMN`
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
139
|
-
physical
|
|
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.
|
|
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.
|
|
112
|
-
"@jarenjs/json": "^0.
|
|
113
|
-
"@jarenjs/validate": "^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"
|