@jarenjs/db 0.49.2 → 0.66.1
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 +420 -71
- package/README.md +711 -79
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +309 -45
- package/docs/LIVE-FORMAT.md +156 -19
- package/docs/MIGRATION-FORMAT.md +247 -40
- package/docs/MODEL-FORMAT.md +968 -86
- package/package.json +21 -8
- package/schemas/jaren-migration.draft-07.schema.json +73 -0
- package/schemas/jaren-migration.schema.json +73 -0
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +255 -44
- package/src/cli.js +337 -50
- package/src/cursor.js +411 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +125 -11
- package/src/dialect.js +267 -112
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +245 -12
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +503 -69
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +18 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit-model.js +14 -0
- package/src/emit.js +349 -51
- package/src/entity.js +102 -59
- package/src/errors.js +430 -2
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -19
- package/src/introspect.js +583 -0
- package/src/jobs.js +870 -99
- package/src/json-bytes.js +58 -0
- package/src/live-time.js +12 -3
- package/src/live.js +11 -1
- package/src/maintenance.js +175 -0
- package/src/migrate.js +606 -333
- package/src/model.js +241 -8
- package/src/plan.js +1238 -160
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1748 -312
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1672 -276
- package/src/tracker.js +367 -68
- package/src/udf.js +88 -7
- package/types/index.d.ts +1246 -32
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +72 -3
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +81 -3
- package/types/wasm.d.ts +21 -0
- package/dist/types/algebra.d.ts +0 -230
- package/dist/types/app.d.ts +0 -49
- package/dist/types/capture.d.ts +0 -85
- package/dist/types/cli.d.ts +0 -2
- package/dist/types/dag-job.d.ts +0 -40
- package/dist/types/ddl.d.ts +0 -229
- package/dist/types/derive.d.ts +0 -250
- package/dist/types/dialect.d.ts +0 -154
- package/dist/types/dialects/sqlite.d.ts +0 -9
- package/dist/types/driver.d.ts +0 -110
- package/dist/types/drivers/bun.d.ts +0 -47
- package/dist/types/drivers/node.d.ts +0 -37
- package/dist/types/drivers/wasm.d.ts +0 -65
- package/dist/types/emit-model.d.ts +0 -44
- package/dist/types/emit.d.ts +0 -75
- package/dist/types/entity.d.ts +0 -23
- package/dist/types/errors.d.ts +0 -170
- package/dist/types/graph.d.ts +0 -28
- package/dist/types/index.d.ts +0 -37
- package/dist/types/jobs.d.ts +0 -140
- package/dist/types/knn.d.ts +0 -69
- package/dist/types/live-time.d.ts +0 -141
- package/dist/types/live.d.ts +0 -64
- package/dist/types/migrate.d.ts +0 -170
- package/dist/types/model.d.ts +0 -36
- package/dist/types/patch-sql.d.ts +0 -37
- package/dist/types/plan.d.ts +0 -142
- package/dist/types/profile.d.ts +0 -80
- package/dist/types/query.d.ts +0 -112
- package/dist/types/residual.d.ts +0 -64
- package/dist/types/series.d.ts +0 -227
- package/dist/types/store.d.ts +0 -60
- package/dist/types/tracker.d.ts +0 -43
- package/dist/types/typed.d.ts +0 -15
- package/dist/types/types.d.ts +0 -26
- package/dist/types/udf.d.ts +0 -75
- package/dist/types/window.d.ts +0 -52
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -163,9 +163,9 @@ Three things follow, and none of them is optional:
|
|
|
163
163
|
per-mapping cell.
|
|
164
164
|
|
|
165
165
|
**What it costs, both halves** (`benchmark/spatial.js`, the store's own
|
|
166
|
-
rows over 50 000 points): the same `$within` measures <!--
|
|
167
|
-
costs <!--
|
|
168
|
-
connection, the same probe is <!--
|
|
166
|
+
rows over 50 000 points): the same `$within` measures <!--fact:spatial.rtreeStore-->0.46 ms against 2 ms — 4.3× in the R\*Tree's favour<!--/fact-->, and loading them
|
|
167
|
+
costs <!--fact:spatial.rtreeLoad-->718 ms against 399 ms for 50,000 documents in one transaction — 1.8× the write cost<!--/fact-->. Isolated from the store on a raw
|
|
168
|
+
connection, the same probe is <!--fact:spatial.rtree-->0.3 ms against 1.9 ms — 6.4× in the R\*Tree's favour<!--/fact-->. Read speed bought with write cost and a
|
|
169
169
|
second table: choose it deliberately, per index, which is why it is
|
|
170
170
|
neither automatic nor a store-wide option.
|
|
171
171
|
|
|
@@ -198,11 +198,11 @@ Three things follow:
|
|
|
198
198
|
|
|
199
199
|
**What it buys, and what it costs.** Measured against the same query
|
|
200
200
|
over a collection with no such column — the whole embedding parsed out
|
|
201
|
-
of the stored JSON per row — the column is worth <!--
|
|
202
|
-
on the read, and costs <!--
|
|
201
|
+
of the stored JSON per row — the column is worth <!--fact:vector.jsonDoc-->15.6× the plan at 10,000 × 768<!--/fact-->
|
|
202
|
+
on the read, and costs <!--fact:vector.write-->10.2 s against 4.9 s for 50,000 documents in one transaction — 2.1× the write cost<!--/fact-->
|
|
203
203
|
on the way in, because every write pays a JSON round trip of the member
|
|
204
204
|
plus the normalize and the pack. On
|
|
205
|
-
disk it is <!--
|
|
205
|
+
disk it is <!--fact:vector.storage-->3,072 B packed against 16,141 B as a JSON number array inside the document — 5.3× smaller<!--/fact-->
|
|
206
206
|
per vector — smaller than the member, and *added* to it, since the
|
|
207
207
|
document still carries what the column is derived from. Choose it the
|
|
208
208
|
way `physical: 'rtree'` is chosen: per index, with both halves in view
|
|
@@ -465,6 +465,19 @@ raise. The promotion therefore requires the schema to type the member
|
|
|
465
465
|
as an array or an object and nothing else; a store that wants the
|
|
466
466
|
engine's refusal instead keeps `compileSchema` injected.
|
|
467
467
|
|
|
468
|
+
**The same rule, for an interval.** `$overlaps` (§8.16) promotes over a
|
|
469
|
+
member the schema types as an object whose `start` and `end` are both
|
|
470
|
+
REQUIRED and both numeric — and only numeric, since an RFC 3339 bound
|
|
471
|
+
is a good instant to the engine and no epoch column compares against
|
|
472
|
+
it. Under that declaration a bound that is absent, textual or `null` is
|
|
473
|
+
a row the collection cannot hold, so the pushed comparison cannot swallow
|
|
474
|
+
a refusal the engine would have raised. One malformed case remains
|
|
475
|
+
declarable by no schema keyword — a span whose `end` is at or before its
|
|
476
|
+
`start` — so the statement keeps every one of those rows and the operator
|
|
477
|
+
raises over them, exactly as it would have. Both bounds must map to
|
|
478
|
+
declared columns; an unmapped pair pushes nothing and `strict: true`
|
|
479
|
+
names it (`JD0010`).
|
|
480
|
+
|
|
468
481
|
Under `physical: 'rtree'` the same rule is enforced in SQL, by the
|
|
469
482
|
`WHEN <stem>_w IS NOT NULL` guard on the sync triggers: **a document
|
|
470
483
|
with no bounded position is ABSENT from the virtual table**, so the
|
|
@@ -553,6 +566,11 @@ the derived-column mapping branches on (§3.1), so a model that declares
|
|
|
553
566
|
a spatial index is portable across all three drivers and the physical
|
|
554
567
|
shape it produces is not.
|
|
555
568
|
|
|
569
|
+
Closing a Bun connection finalizes its live prepared statements before
|
|
570
|
+
closing the database, so releasing a file does not wait for garbage
|
|
571
|
+
collection. Statement tracking uses weak references and does not retain
|
|
572
|
+
past queries for the connection's lifetime.
|
|
573
|
+
|
|
556
574
|
`rtree` is read from the library's compile options (`ENABLE_RTREE`) and
|
|
557
575
|
is the second mapping branch: a `derive: 'bbox'` index that declares
|
|
558
576
|
`physical: 'rtree'` (§2.1) opens on a build without the module as the
|
|
@@ -582,12 +600,96 @@ composition is sync-capable and adds none); the measured difference is
|
|
|
582
600
|
the price of portability, published with the benchmarks rather than
|
|
583
601
|
waved away.
|
|
584
602
|
|
|
585
|
-
**
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
`journalMode`
|
|
589
|
-
|
|
590
|
-
`
|
|
603
|
+
**Connection configuration is a closed, validated set, read back
|
|
604
|
+
after it is applied.** `openStore` configures exactly eight
|
|
605
|
+
connection pragmas, each by its own option: `busyTimeout` (ms,
|
|
606
|
+
default **5000**), `journalMode` (`delete` | `truncate` | `persist` |
|
|
607
|
+
`memory` | `wal` | `off`, default **`wal`** on a writable file),
|
|
608
|
+
`synchronous` (`off` | `normal` | `full` | `extra`), `walAutocheckpoint`
|
|
609
|
+
(pages, `0` disables), `journalSizeLimit` (bytes, `-1` for none),
|
|
610
|
+
`cacheSize` (pages, or negative KiB), `mmapSize` (bytes) and
|
|
611
|
+
`tempStore` (`default` | `file` | `memory`). An option naming any
|
|
612
|
+
other pragma is refused `JD0006` — `foreign_keys` among them, which the
|
|
613
|
+
model requires ON and verifies per connection — and a value outside a
|
|
614
|
+
pragma's set is API misuse. A driver's capability table declares the
|
|
615
|
+
pragmas its binding applies (`configurablePragmas`); a request outside
|
|
616
|
+
that declaration is `JD0007`, as is an explicit `journalMode` on a
|
|
617
|
+
read-only store, whose journal-mode write the engine refuses. After the
|
|
618
|
+
open sequence every declared pragma is read back and the read values
|
|
619
|
+
are what `store.capabilities.pragmas` carries; a requested value the
|
|
620
|
+
engine did not take is `JD0008` and the store does not open. A
|
|
621
|
+
`:memory:` database keeps journal mode `memory` whatever is asked and
|
|
622
|
+
answers nothing for `mmap_size`, so those two are not written there and
|
|
623
|
+
the report says what the engine answers (`'memory'`, `null`).
|
|
624
|
+
`store.capabilities.busyTimeoutMs` and `store.capabilities.journalMode`
|
|
625
|
+
are the same two read-back values under their long-published names.
|
|
626
|
+
|
|
627
|
+
**Maintenance is a typed operation, never raw SQL.** Four store members
|
|
628
|
+
run what an operator runs on a production database, each under the
|
|
629
|
+
store gate (so none interleaves an in-flight write) and each answering
|
|
630
|
+
the engine's own row as typed data: `checkpoint({ mode })` runs
|
|
631
|
+
`PRAGMA wal_checkpoint` (`passive` | `full` | `restart` | `truncate`,
|
|
632
|
+
default `passive`) and answers `{ busy, logFrames, checkpointedFrames }`
|
|
633
|
+
— the engine's numbers, `-1` on a database that is not in WAL mode; a
|
|
634
|
+
second passive checkpoint reports the same counts as the first (the
|
|
635
|
+
frames stay in the log until a writer restarts it) and a second
|
|
636
|
+
`truncate` reports zeros. `integrityCheck({ limit })` answers
|
|
637
|
+
`{ ok, problems }`, the engine's rows verbatim — corruption is the
|
|
638
|
+
result, never a throw. `foreignKeyCheck()` answers
|
|
639
|
+
`{ ok, violations: [{ table, rowId, parent, fkid }] }`. `optimize()`
|
|
640
|
+
answers `{ ran: true }`, because `PRAGMA optimize` reports nothing and
|
|
641
|
+
this store invents no statistics. `store.capabilities.maintenance`
|
|
642
|
+
carries one boolean per operation: `false` where the driver's binding
|
|
643
|
+
does not declare it and, for `checkpoint` and `optimize`, on a
|
|
644
|
+
read-only store (the engine would answer a checkpoint there with a
|
|
645
|
+
silent no-op) — and a call is refused `JD2077` exactly where the report
|
|
646
|
+
says `false`. A driver failure inside an operation is `JD2078` with
|
|
647
|
+
the original as `cause`. None of them touches the change log's durable
|
|
648
|
+
watermark (LIVE-FORMAT §5).
|
|
649
|
+
|
|
650
|
+
**A backup is published whole or not at all.** `backupTo(targetPath,
|
|
651
|
+
{ rate, onProgress, signal, checkpoint })` copies a live store through
|
|
652
|
+
the platform's online-backup API — writers proceed meanwhile — and
|
|
653
|
+
answers `{ path, pages, checkpoint }`. The copy is written to a
|
|
654
|
+
temporary sibling of the target (`<target>.jaren-tmp-<suffix>`, in the
|
|
655
|
+
same directory so the rename is one file system's) and renamed onto the
|
|
656
|
+
target only once the platform reported the copy complete; a
|
|
657
|
+
cancellation (`JD2079`, honoured between pages at the `rate` the
|
|
658
|
+
platform reports progress — the API takes no signal, so the check runs
|
|
659
|
+
in its progress callback), a copy failure or a rename failure removes
|
|
660
|
+
the temporary file and leaves the target untouched. A `checkpoint`
|
|
661
|
+
mode (default `passive`; `false` skips it, and a read-only store skips
|
|
662
|
+
it by default) fixes the snapshot boundary first, under the store gate.
|
|
663
|
+
Progress events are the platform's `{ totalPages, remainingPages }`
|
|
664
|
+
verbatim, and the last one may still carry a remainder: the platform
|
|
665
|
+
emits no zero event, completion is the resolved copy. The capability is
|
|
666
|
+
`store.capabilities.maintenance.backup` — the Node binding's; every
|
|
667
|
+
other binding reports `false` and refuses `JD2077`. What is NOT
|
|
668
|
+
decided here: where backups go, how they are named, encrypted, rotated
|
|
669
|
+
or retained — the host's policy.
|
|
670
|
+
|
|
671
|
+
**Cancellation is honoured where the driver can honour it, and the
|
|
672
|
+
report says where.** Every operation that runs more than one unit of
|
|
673
|
+
work takes `{ signal, deadline }` and checks them BETWEEN units, on the
|
|
674
|
+
clock the store's runtime record supplies — never inside a statement,
|
|
675
|
+
because no shipped SQLite binding exposes an interrupt.
|
|
676
|
+
`store.capabilities.cancellation` states the granularity per
|
|
677
|
+
lifecycle: `query: 'row'` (a call before it runs, a cursor or page at
|
|
678
|
+
every row boundary — `JD2072`), `queue: true` (a call still waiting for
|
|
679
|
+
the open transaction leaves the queue — `JD2064`), `migration: 'step'`
|
|
680
|
+
(between migrations, between steps and between the batches of a data
|
|
681
|
+
step — `JD2080`; the migration in flight rolls back whole), `maintenance:
|
|
682
|
+
'statement'` (before the one statement each operation issues —
|
|
683
|
+
`JD2081`), `backup: 'page'` (between the pages the platform reports —
|
|
684
|
+
`JD2079`), and `midStatement: false` — a filled slot, not an absent
|
|
685
|
+
one; a driver that grows an interrupt flips exactly that member. A
|
|
686
|
+
passed deadline is `JD2075` in every lifecycle. A cursor's own report
|
|
687
|
+
is part of the same honesty: `capabilities.lazyIteration` is probed at
|
|
688
|
+
open, and on a binding whose statements carry no lazy iterator (the
|
|
689
|
+
driver composes `iterate` over `all()`) every cursor reports
|
|
690
|
+
`streaming: 'buffered'` with a `{ construct: 'driver' }` barrier,
|
|
691
|
+
`explain()` says the same, and `strictStreaming` refuses (`JD0037`) —
|
|
692
|
+
never a row stream the driver cannot deliver.
|
|
591
693
|
|
|
592
694
|
The runtime builtin behind a binding is imported lazily inside
|
|
593
695
|
`open()` — never at module scope — so every driver subpath loads under
|
|
@@ -617,6 +719,15 @@ returns a validation function; the function returns `true`/`false` or
|
|
|
617
719
|
and `store.capabilities.validated === false` — a declared downgrade.
|
|
618
720
|
The cost of running without one: the database constraints only see the
|
|
619
721
|
key and the indexed members; everything else is stored as given.
|
|
722
|
+
Stated more sharply, because it changes ANSWERS and not only what is
|
|
723
|
+
stored: a typed generated column carries SQLite affinity, so a value
|
|
724
|
+
that violates the collection schema — the string `'020'` under an
|
|
725
|
+
`integer` path — reads as the integer `20` in the column while the
|
|
726
|
+
document still holds text, and a pushed `$eq: ['$it.n', '20']` finds
|
|
727
|
+
a row the engine, which compares the JSON value, does not. On an
|
|
728
|
+
unvalidated store the native and residual paths agree only over
|
|
729
|
+
documents that conform to the schema; with the hook injected no other
|
|
730
|
+
document is ever stored.
|
|
620
731
|
`@jarenjs/db` never runs a validator of its own; what it imports from
|
|
621
732
|
`@jarenjs/validate` is only the pure same-document `$ref`/`$anchor`
|
|
622
733
|
resolution in `@jarenjs/validate/normalize`, for model compilation.
|
|
@@ -639,6 +750,18 @@ and may itself call `transaction`; each level is one savepoint. A
|
|
|
639
750
|
throw rolls back exactly its own level and rethrows — an outer
|
|
640
751
|
transaction that catches the error continues and its own work
|
|
641
752
|
commits. There is no implicit retry.
|
|
753
|
+
If `COMMIT` or `RELEASE` itself fails, for example on a deferred foreign
|
|
754
|
+
key constraint, that level rolls back before another caller acquires
|
|
755
|
+
the connection. A rollback failure accompanies the original failure in
|
|
756
|
+
an `AggregateError`, retaining the primary failure's code, class and
|
|
757
|
+
driver cause when the store classifies it.
|
|
758
|
+
|
|
759
|
+
**On an asynchronous driver a refused write rejects.** Every write —
|
|
760
|
+
`insert`, `put`, `patch`, `delete`, the entity set's `create`/`update`/
|
|
761
|
+
`delete` and the queue's `enqueue` — answers its coded error (`JD2001`,
|
|
762
|
+
`JD2003`, `JD2005`, …) through the promise it returned: never a
|
|
763
|
+
synchronous throw, and never an unhandled rejection beside a result
|
|
764
|
+
that looks like success.
|
|
642
765
|
|
|
643
766
|
### 5.1 Transaction ownership
|
|
644
767
|
|
|
@@ -653,39 +776,209 @@ So a **top-level transaction owns its connection until it settles**, and
|
|
|
653
776
|
an overlapping one waits its turn. Two concurrent request handlers
|
|
654
777
|
sharing a store both commit, and both report success.
|
|
655
778
|
|
|
779
|
+
**The store the callback receives is the transaction.** `tx.collection`,
|
|
780
|
+
`tx.entity`, `tx.sync`, `tx.jobs` and `tx.saveChanges()` run as the
|
|
781
|
+
transaction's owner, and `tx.transaction()` nests through its savepoint:
|
|
782
|
+
|
|
783
|
+
```js
|
|
784
|
+
await store.transaction(async (tx) => {
|
|
785
|
+
await tx.collection('docs').put(doc, 'a'); // inside this transaction
|
|
786
|
+
await tx.transaction(async (inner) => {
|
|
787
|
+
await inner.collection('docs').put(other, 'b'); // nested savepoint
|
|
788
|
+
});
|
|
789
|
+
});
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
**A root transaction may take the write lock up front.**
|
|
793
|
+
`store.transaction(fn, { mode: 'immediate' })` begins with `BEGIN
|
|
794
|
+
IMMEDIATE` instead of a deferred savepoint. A body that reads before it
|
|
795
|
+
writes — a ledger claim: read the record, decide, insert — otherwise
|
|
796
|
+
meets the read→write upgrade `SQLITE_BUSY` when another connection
|
|
797
|
+
commits between its read and its write, the one busy the busy handler
|
|
798
|
+
cannot retry; with the lock taken first that wait is an ordinary busy
|
|
799
|
+
wait the `busyTimeout` covers, and two processes claiming one key see
|
|
800
|
+
one `new`. The default `'deferred'` is unchanged, `tx.transaction()`
|
|
801
|
+
inside either mode is a savepoint, `signal` and `unitOfWork` behave the
|
|
802
|
+
same, and the synchronous twin has no mode. The open path already
|
|
803
|
+
brackets every first-open object — collection, entity and join tables,
|
|
804
|
+
indexes, the change log and its state row, the job tables — the same
|
|
805
|
+
way (§2.4).
|
|
806
|
+
|
|
807
|
+
**A transaction handle lives exactly as long as its own scope.** Every
|
|
808
|
+
`tx` view is pinned to the exact scope that created it, and every
|
|
809
|
+
stateful member — connection work, unit-of-work bookkeeping like
|
|
810
|
+
`add()`, `tx.stats()`, a lazy `query()` cursor's `next()` — checks that
|
|
811
|
+
pin before reading tracker state or issuing a statement. A handle
|
|
812
|
+
retained past its callback, or an OUTER handle used while an async
|
|
813
|
+
inner savepoint is current, refuses **`JD2070`** naming the live
|
|
814
|
+
callback's handle as the fix; it never falls through to the root and
|
|
815
|
+
never follows a newer scope. Inside an inner transaction, use the inner
|
|
816
|
+
callback's own handle (synchronous nesting is unaffected — nothing can
|
|
817
|
+
interleave while a synchronous body is on the stack). `tx.stats()`
|
|
818
|
+
reports the work captured by its exact scope, and root `store.stats()`
|
|
819
|
+
always reports the root's, whichever scope happens to be current.
|
|
820
|
+
|
|
821
|
+
**A transaction view does not own the store lifetime.** The view
|
|
822
|
+
carries no `close` member, at runtime or in the declarations: closing
|
|
823
|
+
the connection under the view's own savepoint could only corrupt, so
|
|
824
|
+
the root store (or client) remains the only owner of connection
|
|
825
|
+
lifetime.
|
|
826
|
+
|
|
827
|
+
**Jobs have the same two spellings.** Root `store.jobs.*` finite calls
|
|
828
|
+
(and every worker's own claim/renewal/checkpoint/settlement I/O, no
|
|
829
|
+
matter where the worker was created) take the store gate, so an
|
|
830
|
+
unrelated job write can never join an open application transaction's
|
|
831
|
+
fate. `tx.jobs.*` runs as the exact scope — the transactional-outbox
|
|
832
|
+
spelling: an enqueue or settlement there co-commits with the domain
|
|
833
|
+
transaction and rolls back with it. A checkpoint store keeps the
|
|
834
|
+
root-or-scope ownership of the jobs view that created it.
|
|
835
|
+
|
|
836
|
+
**Capture is transparent to transaction options.** `{ capture: true }`
|
|
837
|
+
changes how committed records are translated, never queue cancellation
|
|
838
|
+
or tracker ownership: `store.transaction(fn, { signal, unitOfWork })`
|
|
839
|
+
behaves identically with and without capture — an aborted queued
|
|
840
|
+
callback still never runs (`JD2064`), and `unitOfWork: 'own'` still
|
|
841
|
+
gives the callback a tracker of its own without consuming the root's
|
|
842
|
+
pending state.
|
|
843
|
+
|
|
656
844
|
Nesting is asked for in one of two ways, and the difference is not
|
|
657
845
|
cosmetic:
|
|
658
846
|
|
|
659
847
|
- **Synchronously** — a `transaction` called while an owning callback is
|
|
660
848
|
still on the stack nests, because nothing can interleave there. This is
|
|
661
|
-
`store.sync.transaction` inside `store.sync.transaction
|
|
849
|
+
`store.sync.transaction` inside `store.sync.transaction`, and it is why
|
|
850
|
+
a store-level synchronous call inside a synchronous callback runs as the
|
|
851
|
+
owner rather than waiting.
|
|
662
852
|
- **Through the scope** — an `async` callback has already awaited, so the
|
|
663
853
|
stack cannot say whether a request is its own nested work or an
|
|
664
|
-
unrelated caller. Nest through the store the callback RECEIVED
|
|
854
|
+
unrelated caller. Nest through the store the callback RECEIVED.
|
|
855
|
+
|
|
856
|
+
**A store-level handle is never inside the transaction.** `store.collection`,
|
|
857
|
+
`store.entity`, `store.saveChanges()`, `store.execute`, `store.dataVersion`
|
|
858
|
+
and the whole `store.sync` surface hold the connection for their own
|
|
859
|
+
extent, so their statements cannot fall inside a transaction they are not
|
|
860
|
+
part of and share a rollback they know nothing about. One store is
|
|
861
|
+
therefore safe for a handler per request: an unrelated writer waits for
|
|
862
|
+
the commit and keeps its own fate.
|
|
863
|
+
|
|
864
|
+
What that costs, stated plainly:
|
|
865
|
+
|
|
866
|
+
- A store-level call made while another caller's transaction is open
|
|
867
|
+
**waits** on the connection's gate, under `queueTimeout` (default 5 s,
|
|
868
|
+
the busy-timeout default). `openStore(model, { transactions: 'strict' })`
|
|
869
|
+
refuses at once instead, for a host that would rather see the contention
|
|
870
|
+
than pay for it.
|
|
871
|
+
- A store-level call **awaited from inside its own transaction** is a
|
|
872
|
+
self-wait: the store cannot tell it from an unrelated caller, so it
|
|
873
|
+
queues and, at `queueTimeout`, becomes `JD0012` whose message names
|
|
874
|
+
`tx.collection` / `tx.entity` / `tx.entities` as the fix. Bounded and
|
|
875
|
+
named, never a hang.
|
|
876
|
+
- `store.transaction(fn, { signal })` abandons a call that is still
|
|
877
|
+
**queued** — the callback never runs, and `JD2064` says so. A
|
|
878
|
+
transaction that has already taken the connection runs to its own end.
|
|
879
|
+
- A store-level **cursor** — `collection.query()`, `entity.cursor()`,
|
|
880
|
+
`entity.loadCursor()` — takes the gate **per pull**, not for its life:
|
|
881
|
+
constructing it touches no connection, each `next()` holds the
|
|
882
|
+
connection for exactly the source work one item needs (the first pull
|
|
883
|
+
prepares and opens the statement) and releases before it settles, and
|
|
884
|
+
`return()` is admitted the same way — a release the gate refuses (a
|
|
885
|
+
contended `'strict'` store, a queue timeout) still resets the statement
|
|
886
|
+
off-gate and answers `{ done: true }`, and an abort resets it at once,
|
|
887
|
+
because a statement left open until a stranger commits is the worse
|
|
888
|
+
outcome. So a consumer paused between pulls blocks no transaction, and
|
|
889
|
+
a pull made while one is open waits for its commit and observes
|
|
890
|
+
committed state only — never a row a stranger's transaction later rolls
|
|
891
|
+
back. Every cursor iterates a statement of its own, so two cursors over
|
|
892
|
+
one document never invalidate each other. A pull abandoned while queued
|
|
893
|
+
is `JD2064`; an aborted cursor is `JD2072` at its row boundary; a
|
|
894
|
+
passed deadline is `JD2075`; a settled cursor answers `{ done: true }`
|
|
895
|
+
whatever the clock or the gate say. A transaction view's cursors are
|
|
896
|
+
pinned to their exact scope instead (`JD2070`), as above.
|
|
897
|
+
- `store.live()` and `collection.live()` register **under the gate through
|
|
898
|
+
their initial query**: the registration is local but the first result is
|
|
899
|
+
a statement, so it waits for an open transaction like every other
|
|
900
|
+
store-level read and never publishes rows that transaction rolls back
|
|
901
|
+
(LIVE-FORMAT §7). A registration that is refused leaves no live query
|
|
902
|
+
behind. A live query registered from INSIDE a transaction (`tx.live`,
|
|
903
|
+
`tx.collection(name).live`) initializes from that transaction's rows and
|
|
904
|
+
shares its fate: committed, it stays and is maintained; rolled back, it
|
|
905
|
+
is closed with the rows that never existed.
|
|
906
|
+
|
|
907
|
+
**A unit of work's fate is its transaction's.** A tracked `saveChanges()`
|
|
908
|
+
inside a transaction writes its statements immediately — inside the
|
|
909
|
+
transaction the database does hold them, and every later read, plan and
|
|
910
|
+
optimistic guard in that unit of work agrees with that. What waits for
|
|
911
|
+
the commit is the right to *keep* the advance: if the enclosing
|
|
912
|
+
transaction rolls back, the tracker's snapshots are withdrawn to what
|
|
913
|
+
they were before the save, so a caller's retry plans the same statements
|
|
914
|
+
again instead of reporting a success it never had. A nested savepoint
|
|
915
|
+
that rolls back withdraws only what was registered inside it.
|
|
916
|
+
|
|
917
|
+
`@jarenjs/linq/db` projects all of this: `client.transaction(async (tx) =>
|
|
918
|
+
…)` hands the callback a typed client whose `tx.entities.X` and
|
|
919
|
+
`tx.collections.Y` are the transaction's, with a unit of work of its own
|
|
920
|
+
by default so two handlers never see each other's pending state
|
|
921
|
+
(`unitOfWork: 'shared'` opts back into the client's). The typed client
|
|
922
|
+
forwards `tx.savepoints` (§5.2) unchanged, and `tx.store` is the
|
|
923
|
+
underlying `TransactionStore`.
|
|
924
|
+
|
|
925
|
+
### 5.2 Named savepoints: partial rollback without a sentinel exception
|
|
926
|
+
|
|
927
|
+
Structured nesting already gives partial rollback around a callback: an
|
|
928
|
+
inner `tx.transaction(fn)` is one savepoint, and an outer callback that
|
|
929
|
+
catches its error continues. What it cannot express is
|
|
930
|
+
checkpoint-and-continue from a LATER point without throwing for control
|
|
931
|
+
flow. A live transaction view carries that as `tx.savepoints`:
|
|
665
932
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
933
|
+
```js
|
|
934
|
+
await store.transaction(async (tx) => {
|
|
935
|
+
await tx.savepoints.create('before-optional-import');
|
|
936
|
+
await tx.collection('docs').put(optionalDoc, 'optional');
|
|
937
|
+
|
|
938
|
+
if (!accepted) {
|
|
939
|
+
await tx.savepoints.rollbackTo('before-optional-import');
|
|
940
|
+
// the checkpoint remains active and may be rolled back to again
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
await tx.savepoints.release('before-optional-import');
|
|
944
|
+
});
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
The synchronous twin is `tx.sync.savepoints`, answering values. Root
|
|
948
|
+
`Store`, root `Client`, workers and checkpoint stores expose **none** of
|
|
949
|
+
it: only the transaction that owns the connection may move its stack,
|
|
950
|
+
and a stale or cross-scope view is `JD2070` before any label is even
|
|
951
|
+
looked at.
|
|
952
|
+
|
|
953
|
+
- **The label never reaches SQL.** It is a map key and diagnostic for
|
|
954
|
+
that exact transaction; the driver generates the same monotonic
|
|
955
|
+
`jaren_sp_*` identifier structured nesting uses, so both savepoint
|
|
956
|
+
kinds share one engine stack and cannot cross-release one another. A
|
|
957
|
+
structured inner transaction gets its own exact namespace — it cannot
|
|
958
|
+
target an outer label, and the `JD2070` rule keeps the outer view
|
|
959
|
+
from destroying an async inner savepoint.
|
|
960
|
+
- **A blank, duplicate or unknown label is `JD2071`**, raised before
|
|
961
|
+
any statement, so the database and the settlement/capture marks are
|
|
962
|
+
untouched.
|
|
963
|
+
- **`rollbackTo` follows the engine's semantics exactly.** The target
|
|
964
|
+
savepoint stays active (a second rollback to it is defined) while
|
|
965
|
+
every checkpoint created after it is invalidated; the rows after the
|
|
966
|
+
target are gone. In-memory effects follow the database: settlement
|
|
967
|
+
effects registered after the checkpoint run their rollback halves in
|
|
968
|
+
reverse — a `saveChanges()` advance after the checkpoint is
|
|
969
|
+
withdrawn, its entity intention pending again, so a corrected
|
|
970
|
+
`saveChanges()` retries it (and an enclosing rollback still withdraws
|
|
971
|
+
that later advance). Direct collection writes and `tx.jobs` writes
|
|
972
|
+
are undone by SQLite itself. Session capture observes the engine's
|
|
973
|
+
final changeset; journal capture truncates to the checkpoint's mark —
|
|
974
|
+
the two modes agree.
|
|
975
|
+
- **`release` keeps the rows.** It removes the target and every later
|
|
976
|
+
checkpoint without running rollback effects: those rows remain part
|
|
977
|
+
of the owning transaction, and their tracker withdrawals stay
|
|
978
|
+
registered until outer settlement. An outer commit or rollback
|
|
979
|
+
invalidates whatever names the callback left active.
|
|
980
|
+
- No observer delivery or persisted capture record occurs before the
|
|
981
|
+
owning transaction commits, exactly as everywhere else.
|
|
689
982
|
|
|
690
983
|
## 6. Identity
|
|
691
984
|
|
|
@@ -717,16 +1010,24 @@ error.
|
|
|
717
1010
|
| code | raised when |
|
|
718
1011
|
|---|---|
|
|
719
1012
|
| `JD0001` | the SQLite library is below the supported floor |
|
|
720
|
-
| `JD0002` | the
|
|
1013
|
+
| `JD0002` | the existing database disagrees with the declared model, or the open failed in the driver |
|
|
721
1014
|
| `JD0003` | the driver binding is unavailable on this runtime |
|
|
722
1015
|
| `JD0004` | a declared index cannot be mapped to a column |
|
|
723
1016
|
| `JD0005` | the model document is invalid |
|
|
1017
|
+
| `JD0006` | an open option named a pragma this store does not configure |
|
|
1018
|
+
| `JD0007` | the pragma cannot be applied on this driver or store |
|
|
1019
|
+
| `JD0008` | a pragma did not take: the read-back disagrees with the request |
|
|
724
1020
|
| `JD0010` | strict mode refused a residual |
|
|
725
1021
|
| `JD0011` | the profile refused the document |
|
|
726
1022
|
| `JD0012` | work waited too long for the open transaction to settle |
|
|
727
1023
|
| `JD0030` | an unknown x-entity member was declared |
|
|
728
1024
|
| `JD0031` | relation declarations contradict each other |
|
|
729
1025
|
| `JD0032` | the include specification is invalid |
|
|
1026
|
+
| `JD0033` | an entity query names no entity array |
|
|
1027
|
+
| `JD0034` | a tracked cursor needs a bare entity return |
|
|
1028
|
+
| `JD0035` | the continuation does not belong to this ordering |
|
|
1029
|
+
| `JD0036` | a snapshot page needs an immutable ordering |
|
|
1030
|
+
| `JD0037` | strictStreaming refused a plan that buffers |
|
|
730
1031
|
| `JD0040` | the save spans a relation cycle |
|
|
731
1032
|
| `JD0050` | live queries require change capture |
|
|
732
1033
|
| `JD0051` | the demanded live mode is unavailable |
|
|
@@ -745,16 +1046,154 @@ error.
|
|
|
745
1046
|
| `JD2060` | the maintained live state exceeded its bound |
|
|
746
1047
|
| `JD2061` | another context owns the database |
|
|
747
1048
|
| `JD2062` | the store closed with job handlers still in flight |
|
|
1049
|
+
| `JD2063` | the store is closed |
|
|
1050
|
+
| `JD2064` | the call was aborted while it waited for the open transaction |
|
|
1051
|
+
| `JD2065` | the job is not leased — it is unknown, or already settled |
|
|
1052
|
+
| `JD2066` | the lease was superseded by a newer claim or renewal |
|
|
1053
|
+
| `JD2067` | the lease expired before the call |
|
|
1054
|
+
| `JD2068` | a settling call needs the lease the claim returned |
|
|
1055
|
+
| `JD2069` | a resumed run does not match the workflow or input it was checkpointed under |
|
|
1056
|
+
| `JD2070` | the transaction handle does not belong to the live scope |
|
|
1057
|
+
| `JD2071` | the savepoint label is blank, duplicate or unknown |
|
|
1058
|
+
| `JD2072` | the call was aborted before its next row |
|
|
1059
|
+
| `JD2073` | an include exceeded its per-root bound |
|
|
1060
|
+
| `JD2074` | an item exceeds the page byte bound |
|
|
1061
|
+
| `JD2075` | the deadline passed before the next unit of work |
|
|
1062
|
+
| `JD2076` | an item exceeds the profile byte bound |
|
|
1063
|
+
| `JD2077` | the maintenance operation is unavailable on this store |
|
|
1064
|
+
| `JD2078` | the maintenance operation failed |
|
|
1065
|
+
| `JD2079` | the backup was cancelled |
|
|
1066
|
+
| `JD2080` | the migration was cancelled between steps |
|
|
1067
|
+
| `JD2081` | the maintenance operation was cancelled |
|
|
1068
|
+
| `JD2082` | the database or its disk is full |
|
|
1069
|
+
| `JD2083` | the database is read-only |
|
|
1070
|
+
| `JD2084` | a disk I/O error |
|
|
1071
|
+
| `JD2085` | the database file is corrupt or not a database |
|
|
1072
|
+
| `JD2086` | a seek anchor came back with a type the plan did not declare |
|
|
1073
|
+
| `JD2087` | the connection to the database was lost |
|
|
1074
|
+
| `JD2088` | the transaction was aborted by an earlier failure in it |
|
|
1075
|
+
| `JD2089` | the statement was cancelled by the server |
|
|
1076
|
+
| `JD2090` | worker generation lost; reopen, never automatically replay; retryable only outside a transaction |
|
|
1077
|
+
| `JD2091` | bounded worker/pool admission overflow; retryable, with queue depth |
|
|
1078
|
+
| `JD2092` | a worker row, compatibility result or remote identity count exceeds its declared bound |
|
|
1079
|
+
| `JD2093` | malformed worker protocol request |
|
|
1080
|
+
| `JD2094` | invalid or uncommitted durable snapshot; reopen the last committed version |
|
|
748
1081
|
|
|
749
1082
|
The table above is proven in sync with the runtime `DB_CODES` table by
|
|
750
1083
|
a test.
|
|
751
1084
|
|
|
1085
|
+
**One classification of driver failures.** Every path that meets a
|
|
1086
|
+
driver error — a collection or entity write, the job queue, the query
|
|
1087
|
+
path, a maintenance operation, the backup, the open sequence —
|
|
1088
|
+
consults one table (`classifyDriverError`), so the same failure arrives
|
|
1089
|
+
under the same code with the same `class` and `retryable` verdict
|
|
1090
|
+
whichever path met it: `busy` (SQLITE_BUSY/LOCKED → `JD2005`,
|
|
1091
|
+
retryable), `full` (`JD2082`), `readonly` (`JD2083`), `io` (`JD2084`),
|
|
1092
|
+
`corrupt` (`JD2085`), `cantopen` and `constraint` (`JD2005`), `duplicate`
|
|
1093
|
+
(a UNIQUE collision on the key column → `JD2001`), `overflow` (a pushed
|
|
1094
|
+
integer aggregate past int64 — never raised: the query path re-runs the
|
|
1095
|
+
document in the engine and answers the double, and `explain().fallback`
|
|
1096
|
+
records it), and the fallback `error` (`JD2005`). A classified error
|
|
1097
|
+
carries `class`, `retryable` and the driver's error as `cause`; a
|
|
1098
|
+
lifecycle that owns its failure code (`JD2078` for maintenance and
|
|
1099
|
+
backup, `JD0002` at open) keeps the code and still carries the class.
|
|
1100
|
+
|
|
1101
|
+
The ENGINE is discriminated by the evidence the error itself carries,
|
|
1102
|
+
never by a table threaded down from the caller: a SQLite binding
|
|
1103
|
+
attaches a numeric result code, a PostgreSQL one attaches a
|
|
1104
|
+
five-character SQLSTATE. Both land in the classes above, and three
|
|
1105
|
+
conditions a single-writer file database does not have get their own:
|
|
1106
|
+
`connection` (`JD2087`, retryable — SQLSTATE class 08 and the
|
|
1107
|
+
administrator's own terminations), `aborted` (`JD2088` — SQLSTATE
|
|
1108
|
+
`25P02`, a statement issued after an earlier failure inside the same
|
|
1109
|
+
transaction) and `cancelled` (`JD2089` — SQLSTATE `57014`). A
|
|
1110
|
+
serialization failure or a deadlock (`40001`, `40P01`) is `busy` and
|
|
1111
|
+
retryable, which is the same verdict, and the same caller branch, a
|
|
1112
|
+
locked SQLite file gets.
|
|
1113
|
+
An engine error thrown inside a pushed user function (`JQ…`) is not a
|
|
1114
|
+
driver error: it passes through untouched, relocated onto the caller's
|
|
1115
|
+
document path (`/$where/…`, never the hatch's `/$return/…`).
|
|
1116
|
+
|
|
1117
|
+
## 7A. Model-declared index expressions
|
|
1118
|
+
|
|
1119
|
+
An index may name a computation instead of a member:
|
|
1120
|
+
|
|
1121
|
+
```json
|
|
1122
|
+
{
|
|
1123
|
+
"name": "by_lower_email",
|
|
1124
|
+
"expression": { "call": "lower", "args": [{ "member": "$.email" }] },
|
|
1125
|
+
"unique": true
|
|
1126
|
+
}
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
`expression` is mutually exclusive with `path` and with `derive`
|
|
1130
|
+
(`JD0004`): an expression names the members it reads itself, and a
|
|
1131
|
+
derived spatial column IS an expression this format spells for you.
|
|
1132
|
+
|
|
1133
|
+
**The vocabulary is closed — three node kinds and no fourth**, and none
|
|
1134
|
+
of them is SQL text:
|
|
1135
|
+
|
|
1136
|
+
| node | meaning |
|
|
1137
|
+
|---|---|
|
|
1138
|
+
| `{ "member": "$.a.b" }` | a singular JSONPath expression into the stored document |
|
|
1139
|
+
| `{ "value": 1 }` | a JSON string, number or boolean. A `null` or a compound has no place in an index expression |
|
|
1140
|
+
| `{ "call": "lower", "args": [ … ] }` | a function the HOST declared, applied to its arguments in order |
|
|
1141
|
+
|
|
1142
|
+
Argument order is significant: `sub(a, b)` and `sub(b, a)` are
|
|
1143
|
+
different expressions and different columns. An expression nests at
|
|
1144
|
+
most eight deep.
|
|
1145
|
+
|
|
1146
|
+
**A function is DECLARED by the host, never created by the store.**
|
|
1147
|
+
`openStore(model, { expressions })` takes one declaration per name:
|
|
1148
|
+
|
|
1149
|
+
```jsonc
|
|
1150
|
+
{
|
|
1151
|
+
"lower": {
|
|
1152
|
+
"arity": 1,
|
|
1153
|
+
"deterministic": true, // required, and never inferred
|
|
1154
|
+
"apply": (value) => …, // an engine that registers functions calls this
|
|
1155
|
+
"sql": "lower" // one that cannot calls this IMMUTABLE function
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
Why a declaration rather than an escape hatch: **an index over a
|
|
1161
|
+
function is a schema dependency.** A database whose column is computed
|
|
1162
|
+
by `lower(…)` cannot be written from a connection that has no `lower`,
|
|
1163
|
+
and a raw-SQL index would have had exactly that hazard with none of the
|
|
1164
|
+
checking. Here the model names the function, every store that opens the
|
|
1165
|
+
model is handed the same declaration, and a store that cannot honour
|
|
1166
|
+
one refuses at open — `JD0004`, before a single statement:
|
|
1167
|
+
|
|
1168
|
+
- a name this store was not given
|
|
1169
|
+
- an arity the expression does not match
|
|
1170
|
+
- a function not declared `deterministic`
|
|
1171
|
+
- no `apply` where the engine computes the value itself
|
|
1172
|
+
- no `sql` name where the engine calls its own — and that name is an
|
|
1173
|
+
identifier, never SQL text
|
|
1174
|
+
|
|
1175
|
+
**How each engine computes it.** SQLite registers `apply` as a
|
|
1176
|
+
deterministic function under a namespaced name (`jaren_x_lower`) — so a
|
|
1177
|
+
model's `lower` never shadows the engine's own — and the column is
|
|
1178
|
+
`GENERATED ALWAYS AS (jaren_x_lower(<member>)) VIRTUAL`. PostgreSQL
|
|
1179
|
+
registers nothing: the column is `GENERATED ALWAYS AS (lower(<member>))
|
|
1180
|
+
STORED` over the immutable function the host promised the server has.
|
|
1181
|
+
Both read the member as its own SCALAR, so `lower` of a string is the
|
|
1182
|
+
same answer on both.
|
|
1183
|
+
|
|
1184
|
+
One column serves every index that declares the same canonical
|
|
1185
|
+
expression, and the canonical form — order-preserving — is what the
|
|
1186
|
+
shape hash and the migration diff read. `store.introspect()` reads the
|
|
1187
|
+
expression back out of the SQL the dialect wrote (each dialect reads its
|
|
1188
|
+
own), so a declared expression index survives the round trip; without
|
|
1189
|
+
the declarations the column is REPORTED as unmapped rather than guessed.
|
|
1190
|
+
|
|
752
1191
|
## 8. The safe execution profile
|
|
753
1192
|
|
|
754
1193
|
A query document that arrives from a tenant, a remote client or a
|
|
755
1194
|
language model can reach a database. Parameter binding makes injection
|
|
756
1195
|
structurally impossible; it does nothing about resource exhaustion or
|
|
757
|
-
cross-tenant reads. A **profile** composes
|
|
1196
|
+
cross-tenant reads. A **profile** composes five independent bounds:
|
|
758
1197
|
|
|
759
1198
|
```js
|
|
760
1199
|
const store = await openStore(model, { driver, profile: 'safe' });
|
|
@@ -763,10 +1202,98 @@ collection.query(doc, { profile: { maxRows: 200, externals: ['min'] } });
|
|
|
763
1202
|
```
|
|
764
1203
|
|
|
765
1204
|
`'safe'` is the default table; a profile object overrides members over
|
|
766
|
-
it.
|
|
1205
|
+
it. A per-call `profile` REPLACES the store's for that call — it is
|
|
1206
|
+
normalized over the `'safe'` defaults, not over the store's profile —
|
|
1207
|
+
so a store-level mandatory predicate or allow-list does not carry into
|
|
1208
|
+
`execute(doc, { profile: { maxRows: 50 } })`: spell the whole profile
|
|
1209
|
+
per call, or set it once on the store and pass none. The defaults: engine limits
|
|
767
1210
|
`{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
|
|
768
1211
|
`maxRows: 1000`, no externals, no host functions, no collations, all
|
|
769
|
-
of the store's collections, no mandatory predicates, no scan refusal
|
|
1212
|
+
of the store's collections, no mandatory predicates, no scan refusal,
|
|
1213
|
+
and no graph caps (`maxIncludedRows`, `maxDepth`, `maxBytes` all
|
|
1214
|
+
`null`), and no member allow-list (`members: null`).
|
|
1215
|
+
|
|
1216
|
+
**One profile, every engine.** A profile — the store's, or the call's
|
|
1217
|
+
through `ExecuteOptions.profile` on `execute`, `query`, `cursor`,
|
|
1218
|
+
`loadCursor`, `page` and `explain` alike — applies identically to
|
|
1219
|
+
collection execution, entity execution (`entity.execute`, the chain's
|
|
1220
|
+
cursor), graph loading (`load`, `loadCursor`, `page`, every include
|
|
1221
|
+
subquery) and store-root execution (`store.execute`). `collections` is
|
|
1222
|
+
the one allow-list and names collections AND entity roots; a document
|
|
1223
|
+
that reads a name outside it is `JD0011` on every engine, with the
|
|
1224
|
+
same reason. `predicates` is keyed by collection or entity name, and
|
|
1225
|
+
an entity's predicate is conjoined into every fetch of that entity —
|
|
1226
|
+
the native statement, each root the residual fetches, the load's root
|
|
1227
|
+
and every include subquery over that entity. `maxRows` bounds every
|
|
1228
|
+
fetch on every engine (`JD2007`), the residual's input rows included:
|
|
1229
|
+
each root an entity residual fetches carries `LIMIT maxRows + 1`. The
|
|
1230
|
+
graph caps are hard maxima an include's own declaration cannot exceed:
|
|
1231
|
+
`maxIncludedRows` refuses an include that declares more rows per root
|
|
1232
|
+
(or `Infinity`), `maxDepth` refuses a deeper load, and `maxBytes`
|
|
1233
|
+
refuses any one item — a document, an entity row, a loaded root graph —
|
|
1234
|
+
larger than that many serialised bytes (`JD2076`).
|
|
1235
|
+
|
|
1236
|
+
**The member allow-list.** `collections` says which roots a document
|
|
1237
|
+
may read; `members` says which MEMBERS of a root it may read. It is
|
|
1238
|
+
keyed by collection or entity name, and each value is a list in the
|
|
1239
|
+
model's own singular index-path spelling:
|
|
1240
|
+
|
|
1241
|
+
```js
|
|
1242
|
+
collection.query(doc, { profile: {
|
|
1243
|
+
members: { docs: ['$.id', '$.n'], User: ['$.id', '$.age'] } } });
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
A root the list does not name is unrestricted; a root the MODEL does
|
|
1247
|
+
not declare is `JD0011` before any statement runs, because a policy
|
|
1248
|
+
that applies to nothing is a policy failing open. Allowing a member
|
|
1249
|
+
allows everything UNDER it (`$.address` allows `$.address.city`) and
|
|
1250
|
+
none of its siblings (`$.address.city` does not allow `$.address`,
|
|
1251
|
+
which would answer the rest of the address). One collector reads every
|
|
1252
|
+
member path a document references — in `$where`, in `$orderby`, in
|
|
1253
|
+
`$return`, in a join condition, in a registered operator's operands —
|
|
1254
|
+
so a denial is the same `JD0011` wherever the member was named, and it
|
|
1255
|
+
names the root, the member and the place in the caller's document.
|
|
1256
|
+
|
|
1257
|
+
Reading a root item WHOLE is refused, not narrowed: `$return: '$it'`,
|
|
1258
|
+
an alias of the binding through `$let`, and a wildcard with no singular
|
|
1259
|
+
prefix each answer members the list does not allow, and no list can
|
|
1260
|
+
cover them. For the same reason a graph `load()`, `page()` or
|
|
1261
|
+
`loadCursor()` over a policed entity is refused — it answers whole
|
|
1262
|
+
documents by definition — and the refusal names the members that ARE
|
|
1263
|
+
allowed, which the document query engine can project.
|
|
1264
|
+
|
|
1265
|
+
**Enforced, or refused — never approximated.** A budget the engine
|
|
1266
|
+
can COUNT is enforced during execution: rows returned, rows
|
|
1267
|
+
materialised, residual-input rows, included rows per root, depth,
|
|
1268
|
+
bytes, and the engine limits. A budget SQLite cannot measure is
|
|
1269
|
+
refused at preflight on plan shape instead: `refuseFullScan` declines
|
|
1270
|
+
a native plan whose own `EXPLAIN QUERY PLAN` shows a full-table scan,
|
|
1271
|
+
and declines an entity residual outright, because the residual reads
|
|
1272
|
+
every row of every referenced root before the engine decides — that
|
|
1273
|
+
is a full-table scan by shape, so it is refused before any fetch
|
|
1274
|
+
rather than estimated. Visited-row and elapsed-time budgets exist
|
|
1275
|
+
only where a driver supplies a progress or interrupt hook; the
|
|
1276
|
+
shipped SQLite drivers supply neither, and `explain().budget` reports
|
|
1277
|
+
`time: 'unavailable'` and `estimatedRows: 'unavailable'` rather than
|
|
1278
|
+
a number nothing measured.
|
|
1279
|
+
|
|
1280
|
+
**`signal` and `deadline`.** `ExecuteOptions.signal` cancels: a call
|
|
1281
|
+
already aborted issues no statement (`JD2072`), and a cursor or page
|
|
1282
|
+
releases its statement at the next row boundary. `deadline` is an
|
|
1283
|
+
epoch-millisecond instant checked before a statement runs and at
|
|
1284
|
+
every row boundary of a cursor or page (`JD2075`) — a row-boundary
|
|
1285
|
+
check, never a statement interrupt, for the reason above. The clock it
|
|
1286
|
+
is checked against is the store's runtime record's `now`
|
|
1287
|
+
(`@jarenjs/core/runtime`; the platform clock with no record), so a
|
|
1288
|
+
caller under an injected clock computes deadlines from that clock.
|
|
1289
|
+
|
|
1290
|
+
**Provenance.** Every `explain()` — collection, entity, graph —
|
|
1291
|
+
carries `budget`: the profile that applied and from where (`{ source:
|
|
1292
|
+
'call' | 'store', name: 'safe' | 'custom' }`, or `null`), every bound
|
|
1293
|
+
it imposed (`rows`, `includedRows`, `depth`, `bytes`, `limits`), the
|
|
1294
|
+
scan verdict (`'refused-by-shape'` or `'unbounded'`), and the two
|
|
1295
|
+
driver slots by name (`time`, `estimatedRows`). A budget nobody can
|
|
1296
|
+
prove was applied is not a budget.
|
|
770
1297
|
|
|
771
1298
|
1. **Engine limits.** The four engine limits ride into every residual
|
|
772
1299
|
compilation, so the JavaScript portion of a query is bounded by the
|
|
@@ -934,6 +1461,38 @@ to build one on — a crude guess would be dishonest. It pushes
|
|
|
934
1461
|
deterministically and this profile is published so the shape of the win
|
|
935
1462
|
is known; add a narrowing predicate or a `LIMIT` and the push pays.
|
|
936
1463
|
|
|
1464
|
+
**`pushable: 'aggregate'` — the whole-sequence fold, in SQL.** A pack
|
|
1465
|
+
entry marked `'aggregate'` is lowered to a registered **SQL aggregate**
|
|
1466
|
+
where the driver has one (`capabilities.aggregateFunctions`): the plan
|
|
1467
|
+
emits `… SELECT jaren_a_<hash>(<the member's column>)`, SQLite drives
|
|
1468
|
+
the accumulation, and the SAME pure function the residual would call
|
|
1469
|
+
folds the values it collected. `$mean`, `$median`, `$variance` and
|
|
1470
|
+
`$stddev` of the statistics pack carry the token.
|
|
1471
|
+
|
|
1472
|
+
The token is a promise about the FOLD, and three rules enforce it:
|
|
1473
|
+
|
|
1474
|
+
- **order-insensitive.** A SQL aggregate visits rows in an order
|
|
1475
|
+
nothing specifies, so only a summary whose value depends on the
|
|
1476
|
+
multiset alone may be declared pushable.
|
|
1477
|
+
- **one sequence operand.** A SQL aggregate's final step sees only what
|
|
1478
|
+
the row steps accumulated, so a second operand does not reach a fold
|
|
1479
|
+
over zero rows. An entry declaring anything but a single
|
|
1480
|
+
`'seq<number>'` operand and a `'number'` result is a `TypeError` at
|
|
1481
|
+
`openStore`, naming the operator — loud, rather than a silent
|
|
1482
|
+
non-promotion. `$percentile`, whose second operand is the percentile,
|
|
1483
|
+
is therefore `pushable: false` and folds in the engine.
|
|
1484
|
+
- **a numeric member that cannot hold `null`.** SQL cannot tell a
|
|
1485
|
+
stored `null` from an absent member and the engine's sequence can, so
|
|
1486
|
+
the promotion needs the same schema-typed path the core `$sum` and
|
|
1487
|
+
`$avg` need. An absent member contributes nothing on either side.
|
|
1488
|
+
|
|
1489
|
+
A grouped fold is not promoted — a `$groupby` outside the fixed
|
|
1490
|
+
temporal bucket is engine work (§6) — and neither is an aggregate under
|
|
1491
|
+
a window. Both answer what the engine answers, and `explain()` names
|
|
1492
|
+
the reason. As with the scalar hatch, a profiled document triggers no
|
|
1493
|
+
registration: the same `$mean` under a profile folds in the residual,
|
|
1494
|
+
and `strict: true` refuses it by name (`JD0010`).
|
|
1495
|
+
|
|
937
1496
|
**The same profile for a spatial predicate.** A `$within` against a
|
|
938
1497
|
LITERAL region takes the hatch only on a collection that declares **no**
|
|
939
1498
|
derived spatial index on the member: where one is declared the
|
|
@@ -945,15 +1504,15 @@ changing state. Measured on the same terms as the `$sqrt` rows
|
|
|
945
1504
|
mean of 20 executions after a warm one; the residual comparator
|
|
946
1505
|
pushes everything BUT the spatial conjunct):
|
|
947
1506
|
|
|
948
|
-
<!--
|
|
1507
|
+
<!--fact:spatial.udfTable-->
|
|
949
1508
|
| shape | pushed (ms) | residual (ms) | verdict |
|
|
950
1509
|
|---|---|---|---|
|
|
951
1510
|
| solo `$within` over a full scan | 94 | 80 | ~even |
|
|
952
1511
|
| indexed `$eq` **and** `$within` (~5 % pass the index) | 5.7 | 52 | push **9.1×** |
|
|
953
1512
|
| `$within` with `LIMIT 10` | 1.9 | 77 | push **41.0×** |
|
|
954
|
-
<!--/
|
|
1513
|
+
<!--/fact-->
|
|
955
1514
|
|
|
956
|
-
So the spatial hatch <!--
|
|
1515
|
+
So the spatial hatch <!--fact:spatial.udfVerdict-->earns its row: 9.1× beside the selective conjunct and 41.0× under the LIMIT<!--/fact-->,
|
|
957
1516
|
by the same rule as `$sqrt`: a sole `$within` over a full scan is a
|
|
958
1517
|
loss (the UDF re-parses every row in the callback, and the exact
|
|
959
1518
|
containment test is dearer than a square root), a `$within` beside
|
|
@@ -964,15 +1523,11 @@ it returns, which is faster than either column above.
|
|
|
964
1523
|
|
|
965
1524
|
**The honest ceiling.** A `pushable:false` operator (a whole-series
|
|
966
1525
|
`$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
|
|
967
|
-
lists no `udfs` for it
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
pushdown additionally waits on `$groupby` pushdown, itself a deliberate
|
|
973
|
-
residual today. The `aggregateFunctions` capability is probed and
|
|
974
|
-
reported regardless, so the day a pack marks `'aggregate'` the driver
|
|
975
|
-
gate is already in place.
|
|
1526
|
+
lists no `udfs` for it, and neither does a `pushable:'aggregate'` entry
|
|
1527
|
+
whose shape the store refuses (above). A registered aggregate lowers
|
|
1528
|
+
only as the top-level fold of a whole selection: inside a `$groupby`'s
|
|
1529
|
+
`$return` the closed BUILT-IN set is what SQL groups by, and a
|
|
1530
|
+
registered summary there stays the engine's.
|
|
976
1531
|
|
|
977
1532
|
## 9. Entities, the `x-entity` vocabulary, relations
|
|
978
1533
|
|
|
@@ -1013,7 +1568,10 @@ names:
|
|
|
1013
1568
|
The schema stays a valid JSON Schema throughout: strip every
|
|
1014
1569
|
`x-entity` member and it accepts and rejects exactly the same values
|
|
1015
1570
|
(test-asserted over a corpus). The vocabulary is invisible to the
|
|
1016
|
-
validator by the same argument as `x-form`.
|
|
1571
|
+
validator by the same argument as `x-form`. A model document may also
|
|
1572
|
+
be written by code — `@jarenjs/linq/model`'s `defineModel()` emits
|
|
1573
|
+
exactly this document ([MODEL-PEN.md](../../linq/docs/MODEL-PEN.md)), and this section stays its
|
|
1574
|
+
one specification.
|
|
1017
1575
|
|
|
1018
1576
|
### 9.2 The `x-entity` vocabulary (a closed set)
|
|
1019
1577
|
|
|
@@ -1033,6 +1591,13 @@ happen, so this vocabulary is deliberately stricter than the
|
|
|
1033
1591
|
validator's ignore-unknown posture — the strictness is local to the
|
|
1034
1592
|
one namespace this package owns.
|
|
1035
1593
|
|
|
1594
|
+
The vocabulary is closed in POSITION as well as in name: `x-entity` is
|
|
1595
|
+
read on an entity's top-level properties (and through their `allOf`,
|
|
1596
|
+
`$defs` and `definitions` blocks) only. A block nested anywhere else —
|
|
1597
|
+
under a property's `properties`, `items`, `anyOf`, … — is never walked
|
|
1598
|
+
for mapping directives, so one found there is `JD0030` at its `docPath`
|
|
1599
|
+
rather than a key, an index or a relation that silently never existed.
|
|
1600
|
+
|
|
1036
1601
|
### 9.3 The hybrid mapping
|
|
1037
1602
|
|
|
1038
1603
|
Stated once, mechanically applied, and returned as data by
|
|
@@ -1042,7 +1607,8 @@ Stated once, mechanically applied, and returned as data by
|
|
|
1042
1607
|
|---|---|
|
|
1043
1608
|
| scalar (`string`/`number`/`integer`/`boolean`) at the top level | a real typed column |
|
|
1044
1609
|
| `format: date-time`/`date` with `column: "integer"` | an epoch-milliseconds `INTEGER` column; the document keeps the RFC 3339 string, the column carries the derived epoch |
|
|
1045
|
-
| `enum` of scalars | a column plus a `CHECK (column IN (…))` |
|
|
1610
|
+
| `enum` of scalars | a column plus a `CHECK (column IN (…))` — a `null` member of the enum is left to the column's nullability, never written into the list |
|
|
1611
|
+
| a union of several scalar types (`['string', 'integer']`) | the JSONB document — a column has one affinity and a union has several; `['integer', 'null']` is that scalar, nullable |
|
|
1046
1612
|
| nested object / array, or `column: "json"` | the JSONB document column, queryable by path exactly as in phase A |
|
|
1047
1613
|
| relation | a foreign-key column, or a join table for many-to-many (§9.4) |
|
|
1048
1614
|
|
|
@@ -1052,13 +1618,39 @@ everything else; a read merges them back. **The absent-versus-null
|
|
|
1052
1618
|
rule, plainly**: for a column-mapped scalar, JSON `null` and absence
|
|
1053
1619
|
both store as SQL `NULL` and read back as ABSENT. A property that
|
|
1054
1620
|
needs present-`null` semantics declares `column: "json"` and stays in
|
|
1055
|
-
the document.
|
|
1621
|
+
the document. The rule reaches the WRITE's return value, not only the
|
|
1622
|
+
read: `create()`, `update()` and `add()` answer the document a read
|
|
1623
|
+
would answer, so a `null` handed in for a column-mapped scalar is
|
|
1624
|
+
absent from what comes back — a returned object never names a member
|
|
1625
|
+
no read will show. An epoch column (§9.3's `column: "integer"` row)
|
|
1626
|
+
keeps its string in the document, present-`null` included.
|
|
1056
1627
|
|
|
1057
1628
|
### 9.4 Relations and referential integrity
|
|
1058
1629
|
|
|
1059
1630
|
Declared on one side, inferred on the other; when both sides declare,
|
|
1060
1631
|
the inverses MUST agree (`JD0031` on any contradiction).
|
|
1061
1632
|
|
|
1633
|
+
What counts as "the same edge" is decided by `via`, not by the pair of
|
|
1634
|
+
entity names: two declarations pair up when they name the same
|
|
1635
|
+
foreign-key property, and a pair is one `many` side and one `one`
|
|
1636
|
+
side. Two `many` sides, or two `one` sides, on one `via` is `JD0031`,
|
|
1637
|
+
and a paired edge must agree on `onDelete`.
|
|
1638
|
+
|
|
1639
|
+
Between one pair of entities, a `many` declaration facing a `one`
|
|
1640
|
+
declaration with a DIFFERENT `via` is `JD0031` as well (the pair
|
|
1641
|
+
disagrees on its key) — the check is per entity pair, so **two
|
|
1642
|
+
one-to-many edges between the same two entities declare their inverse
|
|
1643
|
+
on ONE side only**. The author-and-editor model is written with both
|
|
1644
|
+
edges on `User` (`authored` by `authorId`, `edited` by `editorId`) and
|
|
1645
|
+
the foreign-key properties on `Post`, or with both edges on `Post` and
|
|
1646
|
+
none on `User`; declaring `Post.author` AND `User.edited` makes
|
|
1647
|
+
`User.authored` face `Post.editor` and is refused. Two declarations of
|
|
1648
|
+
the SAME kind with different `via`s are two independent edges — which
|
|
1649
|
+
is how a legitimate cycle is written (`Post.author` by `authorId` and
|
|
1650
|
+
`User.featured` by `featuredPostId`, one-to-one each way).
|
|
1651
|
+
Many-to-many pairs must agree on the join table, and a many-to-many
|
|
1652
|
+
facing a foreign-key relation is `JD0031`.
|
|
1653
|
+
|
|
1062
1654
|
- **one-to-many** — `{ to, many: true, via, onDelete }`: `via` names
|
|
1063
1655
|
the foreign-key property on the TARGET entity (`authorId` on
|
|
1064
1656
|
`Post`). If the target declares that property it MUST be a
|
|
@@ -1096,19 +1688,37 @@ reads and deletes take `{ prop: value, … }`.
|
|
|
1096
1688
|
Applied on write in JavaScript, never by SQL `DEFAULT`, so the value
|
|
1097
1689
|
the application sees and the value stored are the same — and the
|
|
1098
1690
|
behaviour is identical on every driver. `"now"` stamps an RFC 3339
|
|
1099
|
-
UTC string on insert when the property is absent
|
|
1691
|
+
UTC string on insert when the property is absent — the calendar date
|
|
1692
|
+
alone (`YYYY-MM-DD`) on a `format: date` property, so the stamp
|
|
1693
|
+
validates against its own format; `"updated"` stamps
|
|
1100
1694
|
on insert AND on every update, always; `{ "value": … }` fills a
|
|
1101
1695
|
literal when absent; `{ "query": … }` evaluates a query document over
|
|
1102
1696
|
the document being written. Defaults run BEFORE validation, so the
|
|
1103
|
-
injected hook sees the completed document
|
|
1697
|
+
injected hook sees the completed document — with one member exempt: a
|
|
1698
|
+
store-allocated key (`default: "auto"`) is allocated by the database
|
|
1699
|
+
AFTER validation, so a write validates against the schema with that
|
|
1700
|
+
key dropped from `required` (the generated input type marks it
|
|
1701
|
+
optional for the same reason); the document the store answers carries
|
|
1702
|
+
it, and the read shape keeps it required. A `version` property is
|
|
1703
|
+
engine-owned and never defaulted by the caller: an insert without one
|
|
1704
|
+
writes `0` — not SQL `NULL`, which no `WHERE version = ?` guard could
|
|
1705
|
+
match — and every successful write bumps it (§11.5).
|
|
1104
1706
|
|
|
1105
1707
|
### 9.7 Error-code additions
|
|
1106
1708
|
|
|
1107
|
-
The entity engine adds
|
|
1108
|
-
`JD0030` — an unknown `x-entity` member; `JD0031` — relation
|
|
1109
|
-
declarations whose inverses contradict
|
|
1110
|
-
|
|
1111
|
-
|
|
1709
|
+
The entity engine adds four codes to the package's single table (§7):
|
|
1710
|
+
`JD0030` — an unknown or unread `x-entity` member; `JD0031` — relation
|
|
1711
|
+
declarations whose inverses contradict; `JD0033` — an entity query
|
|
1712
|
+
document that binds no entity array (§10.1); `JD0034` — a cursor asked
|
|
1713
|
+
to track (`tracking: true`) over a document that yields no entity
|
|
1714
|
+
document to register: a projection, a count or a window (§10.1, the
|
|
1715
|
+
cursor). Everything else raises the
|
|
1716
|
+
existing codes: `JD0005` for structural model defects (a key, index or
|
|
1717
|
+
version property without a column of its own, a default on a relation,
|
|
1718
|
+
`default: "auto"` off the key, a self-referencing many-to-many, a
|
|
1719
|
+
foreign key onto a composite key), `JD2001` for a duplicate key on
|
|
1720
|
+
`create()` exactly as on a collection `insert`, and `JD2005` for
|
|
1721
|
+
database-refused writes including foreign-key violations.
|
|
1112
1722
|
|
|
1113
1723
|
## 10. Relational translation
|
|
1114
1724
|
|
|
@@ -1122,13 +1732,65 @@ translatable runs the set residual over the fetched entity root,
|
|
|
1122
1732
|
|
|
1123
1733
|
`store.execute(document)` queries the **multi-entity root**: the
|
|
1124
1734
|
engine-side value is `{ <EntityName>: [documents…], … }` and bindings
|
|
1125
|
-
range over `$.<Entity>[*]`.
|
|
1735
|
+
range over `$.<Entity>[*]`. A document that binds no entity array at
|
|
1736
|
+
all — a `$for` over a member the model does not name, or over a
|
|
1737
|
+
scalar — is `JD0033` at compile, never an empty answer. The
|
|
1738
|
+
array-constructor spelling `["$.<Entity>[*]"]`, which `@jarenjs/linq`
|
|
1739
|
+
emits so that an item that is itself an array stays one item
|
|
1740
|
+
(QUERY-PEN §3), names the same whole-entity source; the planner
|
|
1741
|
+
reads through it for collections (`["$[*]"]`) and entities alike.
|
|
1742
|
+
|
|
1743
|
+
An entity set is a provider (QUERY-PEN §8): `store.entity(name)` and
|
|
1744
|
+
`store.sync.entity(name)` carry `execute(document, options)` and
|
|
1745
|
+
`explain(document, options)` — the document is over the multi-entity
|
|
1746
|
+
root and goes to the entity translator whole, exactly as
|
|
1747
|
+
`store.execute` runs it — plus `root` (`"$.<Entity>[*]"`, the one
|
|
1748
|
+
spelling `collectEntityRoots` reads, so a chain binds its items through
|
|
1749
|
+
it) and `scope` (one identity per store, shared by every set, so two
|
|
1750
|
+
sets may be joined in one document). The store itself carries `roots`,
|
|
1751
|
+
the entity names, because it has no single root: a chain over it is
|
|
1752
|
+
refused by name (`JL0007`) rather than answering the mixed rows or
|
|
1753
|
+
counting the sets, and a document naming no entity array stays
|
|
1754
|
+
`JD0033`. A chain's element terminal hands over the one-item window
|
|
1755
|
+
`[<phrase>]` (QUERY-PEN §6); the planner reads through the window as
|
|
1756
|
+
it reads through the packed source — the phrase inside plans as if
|
|
1757
|
+
bare, and the store answers its rows as the ONE array item the engine
|
|
1758
|
+
would (`[]` for none, `[row]` for one, never singleton-unwrapped;
|
|
1759
|
+
`explain().wrapped` says so) — so `toArray()` over an entity set runs
|
|
1760
|
+
the translator, and a two-root equijoin returning a bare binding runs
|
|
1761
|
+
as one statement.
|
|
1762
|
+
This is the shape the differential oracle
|
|
1126
1763
|
can actually prove — the in-memory engine sees exactly the documents
|
|
1127
1764
|
the entity sets return (`test/db/oracle/relations/`). Relation-NAME
|
|
1128
1765
|
navigation (`$.author.name`) is deliberately not query-document sugar:
|
|
1129
1766
|
the engine has no embedded `author` member to walk, so no oracle could
|
|
1130
1767
|
vouch for it. Name-based navigation lives on the `load` surface
|
|
1131
|
-
(§10.4), where results and statement counts are the proof
|
|
1768
|
+
(§10.4), where results and statement counts are the proof — and, by
|
|
1769
|
+
code, on a query producer that lowers a hop to what the engine CAN walk.
|
|
1770
|
+
|
|
1771
|
+
For that, every entity set carries **`relations`**, the relation table
|
|
1772
|
+
a producer may read: a frozen plain record, one entry per declared
|
|
1773
|
+
relation member of the entity, `{ to, kind, via?, fkEntity?, fkTargets?,
|
|
1774
|
+
joinTable?, targetKey }` — `to` the target entity, `kind` one of
|
|
1775
|
+
`oneToOne`/`oneToMany`/`manyToMany`; for a foreign-key relation `via`
|
|
1776
|
+
the key property, `fkEntity` the entity holding it, `fkTargets` the
|
|
1777
|
+
entity it references and `targetKey` the key property it references
|
|
1778
|
+
there (the column a hop's equality compares `via` with; `kind` says
|
|
1779
|
+
which side holds the key: `oneToOne` the declaring entity, `oneToMany`
|
|
1780
|
+
the target); for a many-to-many `joinTable` and the target's `targetKey`.
|
|
1781
|
+
The store (`store.relations`) and every set's `scope` (`scope.relations`)
|
|
1782
|
+
carry the same tables keyed by entity name, so a producer holding one
|
|
1783
|
+
set can follow a hop into another root. `relationTables(entities)` is
|
|
1784
|
+
the one function that derives them. The document format is unchanged:
|
|
1785
|
+
`@jarenjs/linq`'s chain reads the table and lowers `p.author.email` to
|
|
1786
|
+
`{ $for: { r1: '$.User[*]' }, $where: { $eq: ['$r1.id', '$it.authorId'] },
|
|
1787
|
+
$return: '$r1.email' }` (QUERY-PEN §4, relation navigation) — a
|
|
1788
|
+
document a hand could have written, which this translator and the
|
|
1789
|
+
engine both run and the oracle proves (`15-linq-hops.json`). Every such
|
|
1790
|
+
lowered shape is a residual here (§10.6 — a projection, a comparison or
|
|
1791
|
+
an existence test over a phrase rather than a member path, an ordering
|
|
1792
|
+
by a phrase), named by `explain()` and refused under `strict`; none
|
|
1793
|
+
pushes natively in this version.
|
|
1132
1794
|
|
|
1133
1795
|
Per binding, predicates resolve through three reference flavors:
|
|
1134
1796
|
|
|
@@ -1212,26 +1874,137 @@ store.entity('User').load({
|
|
|
1212
1874
|
})
|
|
1213
1875
|
```
|
|
1214
1876
|
|
|
1215
|
-
Per-relation `where`/`orderBy`/`take` apply INSIDE the subquery
|
|
1216
|
-
|
|
1877
|
+
Per-relation `where`/`orderBy`/`take`/`skip` apply INSIDE the subquery
|
|
1878
|
+
(`LIMIT`/`OFFSET` per parent row) — the point where naive loaders fall
|
|
1879
|
+
back to N+1; `after` (§10.5) paginates the ROOT alone, since a keyset
|
|
1880
|
+
cursor is one position in one ordered set, and on an include it is
|
|
1881
|
+
`JD0032`. Clauses compile against
|
|
1217
1882
|
the child's own reference flavors; an untranslatable clause is a
|
|
1218
1883
|
refusal (`JD0032`) naming the include path, never a silent residual.
|
|
1219
1884
|
Include depth is bounded (default 3, override with `maxDepth`);
|
|
1220
1885
|
exceeding it is `JD0032` with the bound printed. A cyclic include
|
|
1221
1886
|
specification is rejected. Unknown relation names are `JD0032` too.
|
|
1222
1887
|
|
|
1888
|
+
**Every include is bounded per root.** One root graph is the unit a
|
|
1889
|
+
graph cursor yields and a page counts, and "one root" is no bound at
|
|
1890
|
+
all if one root may aggregate a million related rows. So a to-many
|
|
1891
|
+
include carries `maxRows` — related rows per parent — and every
|
|
1892
|
+
row-projecting include carries `maxBytes` — serialised bytes of the
|
|
1893
|
+
relation per parent, measured on the JSON text the database projects.
|
|
1894
|
+
A relation that crosses either bound is the coded refusal **`JD2073`**
|
|
1895
|
+
naming the root (entity and key), the member and the bound that was
|
|
1896
|
+
hit — never a truncated graph, which a caller could not tell from a
|
|
1897
|
+
whole one. The refusal names the two supported alternatives: read
|
|
1898
|
+
`{ count: true }` when the size is the question, or page that relation
|
|
1899
|
+
separately. A bound always exists: an include with none declared
|
|
1900
|
+
inherits the store defaults, `INCLUDE_ROWS_DEFAULT` (1000 rows) and
|
|
1901
|
+
`INCLUDE_BYTES_DEFAULT` (1 MiB); an include with a `take` has that
|
|
1902
|
+
window as its row bound. The unbounded case is spelled, never
|
|
1903
|
+
inherited — `maxRows: Infinity` (`null` in the JSON spec) — so loading
|
|
1904
|
+
a relation whole is a decision rather than an oversight. The to-many
|
|
1905
|
+
subquery carries `LIMIT maxRows + 1`, so the bound is detected at the
|
|
1906
|
+
bound instead of after aggregating the whole relation; a `count`
|
|
1907
|
+
include is a number and carries neither. `explainLoad().bounds` lists
|
|
1908
|
+
the bounds every include ran under.
|
|
1909
|
+
|
|
1910
|
+
**The graph cursor.** `store.entity(name).loadCursor(spec, options)` —
|
|
1911
|
+
`graph.cursor(options)` on the client — yields one root graph per
|
|
1912
|
+
pull, its includes attached and bounded as above, from the same one
|
|
1913
|
+
statement `load` runs: the include rows ride inside each root row as
|
|
1914
|
+
the JSON the database projected, so the window is the row itself and
|
|
1915
|
+
there is no second statement per level to hold or release. `return()`
|
|
1916
|
+
releases the statement exactly once; `signal` cancels at a row boundary
|
|
1917
|
+
(`JD2072`). The cursor registers nothing with the unit of work unless
|
|
1918
|
+
`tracking: true` is spelled per call — a snapshot per yielded root is a
|
|
1919
|
+
tracker that grows with the result.
|
|
1920
|
+
|
|
1223
1921
|
### 10.5 Pagination
|
|
1224
1922
|
|
|
1225
1923
|
`$orderby` + `$subsequence` translate to `ORDER BY` + `LIMIT/OFFSET`
|
|
1226
|
-
on the query surface. On the `load` surface
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
(`
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1924
|
+
on the query surface. On the `load` surface `skip` compiles to offset,
|
|
1925
|
+
and `after` — a cursor — to **keyset pagination** (`WHERE …` over the
|
|
1926
|
+
last row's order keys) instead of a growing `OFFSET`; `explainLoad()`
|
|
1927
|
+
reports which strategy ran (`keyset` / `offset` / `none`) — offset
|
|
1928
|
+
degrading quietly on large tables is a well-known footgun, and naming
|
|
1929
|
+
it is cheap.
|
|
1930
|
+
|
|
1931
|
+
**The single-column form.** `after: <value>` with a single ascending or
|
|
1932
|
+
descending ordering over a UNIQUE column — the key, or any `unique:
|
|
1933
|
+
true` column — compiles to `WHERE col > ?` / `< ?`. A scalar cursor
|
|
1934
|
+
over a non-unique column, a document path, or a multi-key ordering is
|
|
1935
|
+
refused (`JD0032`), because such a cursor either skips rows or repeats
|
|
1936
|
+
them wherever the value ties.
|
|
1937
|
+
|
|
1938
|
+
**The composite keyset.** The orderings a list actually wants —
|
|
1939
|
+
`(updatedAt, id)`, `(createdAt, id)`, `(priority desc, id)` — have a
|
|
1940
|
+
non-unique first column. `page()` and a structural `after` compile them
|
|
1941
|
+
as the lexicographic expansion over the declared terms
|
|
1942
|
+
`(k1 dir1, k2 dir2, …)` with **the primary key appended** as the
|
|
1943
|
+
tie-breaker whether or not the caller named it (it is the one column
|
|
1944
|
+
guaranteed unique, and a tie on every declared key would otherwise be a
|
|
1945
|
+
skipped row or a repeated one):
|
|
1946
|
+
|
|
1947
|
+
```sql
|
|
1948
|
+
(k1 > v1) OR (k1 = v1 AND k2 > v2) OR (k1 = v1 AND k2 = v2 AND pk > vpk)
|
|
1949
|
+
```
|
|
1950
|
+
|
|
1951
|
+
with `<` for a descending term, and the `ORDER BY` ending in the key
|
|
1952
|
+
column(s) rather than the row identity. **Null placement agrees with
|
|
1953
|
+
the plan**: every term's null order (`NULLS FIRST`/`LAST`, from `$dir`
|
|
1954
|
+
and `$empty`) is spelled in the expansion too — after a null value come
|
|
1955
|
+
the non-nulls when nulls sort first and nothing when they sort last;
|
|
1956
|
+
after a non-null value come the greater (or lesser) values and, when
|
|
1957
|
+
nulls sort last, the nulls — because SQL's `col > ?` is neither true
|
|
1958
|
+
nor false for `NULL`, and a comparison alone would visit a null-keyed
|
|
1959
|
+
row twice or never. Only mapped columns carry a keyset; a document path
|
|
1960
|
+
in the ordering is refused (`JD0032`).
|
|
1961
|
+
|
|
1962
|
+
**The continuation** a page emits is unsigned, structural and opaque:
|
|
1963
|
+
|
|
1964
|
+
```jsonc
|
|
1965
|
+
{ "order": [{ "column": "updatedAt", "desc": true, "nullsFirst": false },
|
|
1966
|
+
{ "column": "id", "desc": false, "nullsFirst": false }],
|
|
1967
|
+
"keys": [v1], // the declared order-key values, as the document carries them
|
|
1968
|
+
"key": vpk } // the row's primary key (a record for a composite key)
|
|
1969
|
+
```
|
|
1970
|
+
|
|
1971
|
+
`order` is the ordering's identity: a continuation replayed against a
|
|
1972
|
+
different ordering is the refusal `JD0035`, never a wrong page; a
|
|
1973
|
+
continuation whose `keys` do not match the declared key count, or
|
|
1974
|
+
whose `key` is not the entity's key shape, is `JD0035` too. Signing,
|
|
1975
|
+
tenant scoping, expiry and wire encoding are the **host's**: the store
|
|
1976
|
+
has no principal and no key, so any signature it invented would be
|
|
1977
|
+
security theatre — a host that ships a continuation to an untrusted
|
|
1978
|
+
client signs it first.
|
|
1979
|
+
|
|
1980
|
+
**The page.** `store.entity(name).page(spec, { limit, after, maxBytes,
|
|
1981
|
+
consistency, signal })` — `graph.page(options)` on the client — drains
|
|
1982
|
+
the graph cursor in keyset mode and answers `{ items, continuation,
|
|
1983
|
+
hasMore, snapshot }`: never more than `limit` roots (default
|
|
1984
|
+
`PAGE_LIMIT_DEFAULT`, 100) nor more than `maxBytes` serialised bytes
|
|
1985
|
+
(none unless given; every root is bounded by §10.4 regardless), the
|
|
1986
|
+
continuation of the last delivered root, and `hasMore` decided by one
|
|
1987
|
+
peek past the page. A `take` or `skip` in the spec beside `page()` is
|
|
1988
|
+
refused (`JD0032`): the page windows by its limit. **The
|
|
1989
|
+
`item_too_large` rule**: an item that alone exceeds `maxBytes` when
|
|
1990
|
+
nothing has been delivered yet is the coded refusal `JD2074`, raised
|
|
1991
|
+
without advancing the continuation — a caller that retries meets the
|
|
1992
|
+
same refusal, which is the honest answer, never a loop and never a
|
|
1993
|
+
silent breach; an item that does not fit beside earlier ones ends the
|
|
1994
|
+
page before it (`hasMore: true`). A page registers no snapshots unless
|
|
1995
|
+
`tracking: true` is spelled.
|
|
1996
|
+
|
|
1997
|
+
**Snapshot versus live.** A page reports `snapshot: true` only when
|
|
1998
|
+
every order key is immutable — and the primary key is the one column
|
|
1999
|
+
the engine itself guarantees never moves (`update()` refuses to rewrite
|
|
2000
|
+
it). Ordering a live table by a mutable column such as `updatedAt` is
|
|
2001
|
+
**live pagination**, and the page says so (`snapshot: false`): later
|
|
2002
|
+
inserts land where their keys sort, but an existing row whose order
|
|
2003
|
+
key changes between two pages can move across the cursor — it may be
|
|
2004
|
+
seen twice, or not at all — and no cutoff on later writes prevents
|
|
2005
|
+
that. `consistency: 'snapshot'` over such an ordering is refused
|
|
2006
|
+
(`JD0036`) rather than mislabelled; the default `'live'` reports the
|
|
2007
|
+
truth either way. A caller who needs a snapshot orders by the key.
|
|
1235
2008
|
|
|
1236
2009
|
### 10.6 What remains residual
|
|
1237
2010
|
|
|
@@ -1240,16 +2013,62 @@ because joins make residuals more expensive — accompanied by the
|
|
|
1240
2013
|
`EXPLAIN QUERY PLAN` narrative (SQLite exposes no row estimates;
|
|
1241
2014
|
a number appears only where `capabilities.rowEstimates` is filled):
|
|
1242
2015
|
|
|
1243
|
-
-
|
|
2016
|
+
- a binding nothing joins to — the cartesian product a nested-loop plan
|
|
2017
|
+
must never emit by accident; every binding past the first attaches by
|
|
2018
|
+
a column equality to one already joined, and a graph that does not
|
|
2019
|
+
close is the engine's;
|
|
1244
2020
|
- non-equality join predicates, and disjunctions spanning bindings;
|
|
1245
|
-
- `$groupby`
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
2021
|
+
- a `$groupby` whose key is untyped or admits `null`, whose `$return`
|
|
2022
|
+
reads the binding (after a grouping it holds the group's ROWS), or
|
|
2023
|
+
whose `$orderby` names anything but a group key; a window over the
|
|
2024
|
+
groups, or an aggregate of them;
|
|
2025
|
+
- projections (`$return` objects) ACROSS a join — over one binding a
|
|
2026
|
+
nested shape of member paths lowers (§ the projection tree);
|
|
1249
2027
|
- externals against document paths; booleans and `null` at bind time;
|
|
1250
2028
|
- everything phase A already listed (§8 of `QUERY-FORMAT.md`
|
|
1251
2029
|
notwithstanding, the truth table is the contract).
|
|
1252
2030
|
|
|
2031
|
+
Two deviations between a pushed answer and the engine's are DECLARED
|
|
2032
|
+
rather than refused, because in both the database is right by its own
|
|
2033
|
+
arithmetic:
|
|
2034
|
+
|
|
2035
|
+
- `$sum`/`$avg` over `number` paths — SQLite sums with Kahan–Babuška
|
|
2036
|
+
compensation and the engine sums naively, so over `0.1, 0.2, 0.3`
|
|
2037
|
+
the store answers `0.6` and the residual `0.6000000000000001`: equal
|
|
2038
|
+
to within an ulp, never equal by `===` (the differential oracle
|
|
2039
|
+
draws dyadic fractions, which are exact on both sides).
|
|
2040
|
+
- a `$time-bucket` `$groupby` whose aggregated path admits `null`
|
|
2041
|
+
(`['number', 'null']`): the pushed `AVG` skips a `null` reading
|
|
2042
|
+
exactly as the series kernel — and therefore the `$resample`
|
|
2043
|
+
spelling — does, while the engine's `$avg` over a sequence holding
|
|
2044
|
+
`null` raises `JQ2001`; under this one spelling the store answers
|
|
2045
|
+
where the in-memory engine refuses. Everywhere else an ordering or
|
|
2046
|
+
an aggregate over a path that admits `null`, or over a boolean path,
|
|
2047
|
+
is a named residual, so the two paths keep answering alike.
|
|
2048
|
+
|
|
2049
|
+
### 10.7 The join table as a query root
|
|
2050
|
+
|
|
2051
|
+
A declared many-to-many join table is a **read-only query root**:
|
|
2052
|
+
`$.<JoinTable>[*]` binds like any entity array and answers rows carrying
|
|
2053
|
+
exactly its two key columns — `<A>_key` and `<B>_key`, the names the DDL
|
|
2054
|
+
creates — and nothing else, because a join row has no document of its
|
|
2055
|
+
own. It joins to the entities it relates like any other binding, so
|
|
2056
|
+
`Person → Person_Tag → Tag` is one statement over three roots.
|
|
2057
|
+
|
|
2058
|
+
It is a ROOT and not an entity: `store.entity('<JoinTable>')` is
|
|
2059
|
+
`JD2004`, memberships are still written through `link`/`unlink` and the
|
|
2060
|
+
unit of work, and nothing about the table's lifecycle changes. The two
|
|
2061
|
+
namespaces are one, so a join table whose name is also a declared
|
|
2062
|
+
entity's is refused at open (`JD0005`) — `$.X[*]` may mean one thing.
|
|
2063
|
+
|
|
2064
|
+
Because the root exists, a many-to-many **hop** lowers: a chain's
|
|
2065
|
+
`u.labels` becomes two links — the join row that names the membership,
|
|
2066
|
+
then the target row it names — instead of the `JL0105` refusal it was.
|
|
2067
|
+
A relation entry that does not name its join row's columns
|
|
2068
|
+
(`{ joinTable, ownColumn, ownKey, targetColumn, targetKey }` — §10.1)
|
|
2069
|
+
still refuses with that code, because there is then nothing to lower
|
|
2070
|
+
through.
|
|
2071
|
+
|
|
1253
2072
|
|
|
1254
2073
|
## 11. The unit of work
|
|
1255
2074
|
|
|
@@ -1293,8 +2112,19 @@ const report = await store.saveChanges(); // one transaction
|
|
|
1293
2112
|
|
|
1294
2113
|
### 11.2 Explicit updates (the other mode)
|
|
1295
2114
|
|
|
1296
|
-
`set.update(key, changes)` and `set.delete(key)`
|
|
1297
|
-
immediate statement, last-write-wins by contract.
|
|
2115
|
+
`set.create(doc)`, `set.update(key, changes)` and `set.delete(key)`
|
|
2116
|
+
skip tracking: one immediate statement, last-write-wins by contract.
|
|
2117
|
+
`create` applies defaults, validates, inserts, and — for a
|
|
2118
|
+
many-to-many relation whose member the document carries as an array
|
|
2119
|
+
of target keys — writes the join rows in the same transaction, which
|
|
2120
|
+
rolls back whole when a membership names a row that does not exist; a
|
|
2121
|
+
duplicate key is `JD2001`. `update` takes the document's own members
|
|
2122
|
+
only: a relation member (`posts`) is `JD2003` exactly as on `create`,
|
|
2123
|
+
and a membership array (`labels`) — which `create` writes — is
|
|
2124
|
+
`JD2003` on `update` as well, because an explicit update never writes
|
|
2125
|
+
join rows or child rows and never echoes back a member it did not
|
|
2126
|
+
store; a change to a key member is `JD2003` too (the key column would
|
|
2127
|
+
go stale — delete and create). An explicit update
|
|
1298
2128
|
still bumps a declared version property, so optimistic savers observe
|
|
1299
2129
|
the row changed. This is the path reactive layers and job runners use.
|
|
1300
2130
|
|
|
@@ -1337,7 +2167,10 @@ the 100× statement reduction is the point for anything remote.
|
|
|
1337
2167
|
|
|
1338
2168
|
### 11.5 Optimistic concurrency
|
|
1339
2169
|
|
|
1340
|
-
Declare a token with `version: true` (§9.2).
|
|
2170
|
+
Declare a token with `version: true` (§9.2). A row inserted without
|
|
2171
|
+
one starts at `0` — never SQL `NULL`, which no guard could match
|
|
2172
|
+
(§9.6) — so the first tracked save after `create()`/`add()` carries
|
|
2173
|
+
`WHERE version = 0`. Every `saveChanges()`
|
|
1341
2174
|
update and guarded delete carries `WHERE version = ?` (the SNAPSHOT
|
|
1342
2175
|
version) and writes snapshot + 1; a zero-row result is **`JD2040`**
|
|
1343
2176
|
carrying the entity and key, and the whole save rolls back. Without a
|
|
@@ -1352,8 +2185,14 @@ zero rows.) An unguarded delete of a missing row is a no-op.
|
|
|
1352
2185
|
`saveChanges()` is all-or-nothing inside one transaction. On ANY
|
|
1353
2186
|
failure the tracker is left exactly as it was before the call — the
|
|
1354
2187
|
same save can be retried once the cause is gone; a half-applied
|
|
1355
|
-
tracker is worse than a rollback.
|
|
1356
|
-
snapshots (bumped versions, generated keys) and clears pending
|
|
2188
|
+
tracker is worse than a rollback. A save whose statements all succeed
|
|
2189
|
+
advances snapshots (bumped versions, generated keys) and clears pending
|
|
2190
|
+
work **immediately**, even inside an enclosing transaction — inside it
|
|
2191
|
+
the database does hold those rows, and every later read, plan and
|
|
2192
|
+
optimistic guard must agree. The scope that owns the connection holds
|
|
2193
|
+
the exact undo delta: an enclosing rollback (or a named-savepoint
|
|
2194
|
+
rollback past the save, §5.2) withdraws the advance, a nested rollback
|
|
2195
|
+
withdraws only its own effects, and outer commit keeps it.
|
|
1357
2196
|
|
|
1358
2197
|
The return value is data, not a boolean:
|
|
1359
2198
|
|
|
@@ -1367,3 +2206,46 @@ The return value is data, not a boolean:
|
|
|
1367
2206
|
elapsedMs,
|
|
1368
2207
|
}
|
|
1369
2208
|
```
|
|
2209
|
+
|
|
2210
|
+
### 11.7 Membership: `link` and `unlink`
|
|
2211
|
+
|
|
2212
|
+
A many-to-many membership is attached or detached one row at a time
|
|
2213
|
+
through the unit of work, without carrying the whole membership array:
|
|
2214
|
+
|
|
2215
|
+
```js
|
|
2216
|
+
users.link('u1', 'labels', 'admin'); // a key, or a document carrying the key
|
|
2217
|
+
users.unlink(ada, 'labels', { name: 'dev' }); // the own side is a key or a document too
|
|
2218
|
+
const report = await store.saveChanges(); // { joinInserted: 1, joinDeleted: 1, … }
|
|
2219
|
+
```
|
|
2220
|
+
|
|
2221
|
+
- `link`/`unlink` are local, synchronous bookkeeping, like `add`/`put`/
|
|
2222
|
+
`remove`; nothing reaches the database until `saveChanges()`.
|
|
2223
|
+
- The member MUST be a many-to-many relation of the entity — `JD2003`
|
|
2224
|
+
otherwise, naming the relation's kind or the missing declaration. The
|
|
2225
|
+
target is read exactly as a membership array's element is: a key, or
|
|
2226
|
+
a document carrying the target's key (`JD2003` when it carries none).
|
|
2227
|
+
- The own side needs the entity's key. A pending insert whose `auto`
|
|
2228
|
+
key the save allocates has none to attach to and is refused (`JD2003`:
|
|
2229
|
+
"save the entity first, then attach"); a pending insert with a
|
|
2230
|
+
caller-supplied key may be linked in the same save, since join rows
|
|
2231
|
+
run after the inserts (§11.4).
|
|
2232
|
+
- The baseline is the join table **as read at save time**, not a
|
|
2233
|
+
snapshot: linking a member that already exists and unlinking one that
|
|
2234
|
+
does not are no-ops, so a save repeated with the same calls changes
|
|
2235
|
+
nothing (asserted). The last word on one target wins — `unlink` after
|
|
2236
|
+
`link` means unlink.
|
|
2237
|
+
- A `link`/`unlink` beside a `put` carrying the SAME member's membership
|
|
2238
|
+
array folds into that array's key-set difference (§11.3): one intent
|
|
2239
|
+
per entity, own key and member, never two statements racing for one
|
|
2240
|
+
row.
|
|
2241
|
+
- A tracked snapshot's loaded membership array is not rewritten by a
|
|
2242
|
+
saved `link`/`unlink` — a projection stays what it was read as;
|
|
2243
|
+
re-read (`load({ include })`) to see the membership. `discard(key)`
|
|
2244
|
+
drops the key's pending membership changes together with its
|
|
2245
|
+
tracking, and a failed save leaves them pending, as §11.6 promises.
|
|
2246
|
+
- The report counts the rows written under `joinInserted`/`joinDeleted`
|
|
2247
|
+
and `stats().tracker.pendingMemberships` counts the pending
|
|
2248
|
+
(entity, key, member) records.
|
|
2249
|
+
|
|
2250
|
+
Worker/pool options, synchronous cursors, wasm session probing and the browser
|
|
2251
|
+
persistence ladder are specified in [execution hosts](HOSTS.md).
|