@jarenjs/db 0.56.0 → 0.67.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 +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- 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 +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- 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 +17 -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.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/docs/MODEL-FORMAT.md
CHANGED
|
@@ -198,8 +198,8 @@ 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 <!--fact:vector.jsonDoc-->15.
|
|
202
|
-
on the read, and costs <!--fact:vector.write-->10.
|
|
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
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-->
|
|
@@ -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
|
|
@@ -648,6 +750,11 @@ and may itself call `transaction`; each level is one savepoint. A
|
|
|
648
750
|
throw rolls back exactly its own level and rethrows — an outer
|
|
649
751
|
transaction that catches the error continues and its own work
|
|
650
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.
|
|
651
758
|
|
|
652
759
|
**On an asynchronous driver a refused write rejects.** Every write —
|
|
653
760
|
`insert`, `put`, `patch`, `delete`, the entity set's `create`/`update`/
|
|
@@ -669,39 +776,209 @@ So a **top-level transaction owns its connection until it settles**, and
|
|
|
669
776
|
an overlapping one waits its turn. Two concurrent request handlers
|
|
670
777
|
sharing a store both commit, and both report success.
|
|
671
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
|
+
|
|
672
844
|
Nesting is asked for in one of two ways, and the difference is not
|
|
673
845
|
cosmetic:
|
|
674
846
|
|
|
675
847
|
- **Synchronously** — a `transaction` called while an owning callback is
|
|
676
848
|
still on the stack nests, because nothing can interleave there. This is
|
|
677
|
-
`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.
|
|
678
852
|
- **Through the scope** — an `async` callback has already awaited, so the
|
|
679
853
|
stack cannot say whether a request is its own nested work or an
|
|
680
|
-
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`:
|
|
681
932
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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.
|
|
705
982
|
|
|
706
983
|
## 6. Identity
|
|
707
984
|
|
|
@@ -733,10 +1010,13 @@ error.
|
|
|
733
1010
|
| code | raised when |
|
|
734
1011
|
|---|---|
|
|
735
1012
|
| `JD0001` | the SQLite library is below the supported floor |
|
|
736
|
-
| `JD0002` | the
|
|
1013
|
+
| `JD0002` | the existing database disagrees with the declared model, or the open failed in the driver |
|
|
737
1014
|
| `JD0003` | the driver binding is unavailable on this runtime |
|
|
738
1015
|
| `JD0004` | a declared index cannot be mapped to a column |
|
|
739
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 |
|
|
740
1020
|
| `JD0010` | strict mode refused a residual |
|
|
741
1021
|
| `JD0011` | the profile refused the document |
|
|
742
1022
|
| `JD0012` | work waited too long for the open transaction to settle |
|
|
@@ -744,6 +1024,10 @@ error.
|
|
|
744
1024
|
| `JD0031` | relation declarations contradict each other |
|
|
745
1025
|
| `JD0032` | the include specification is invalid |
|
|
746
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 |
|
|
747
1031
|
| `JD0040` | the save spans a relation cycle |
|
|
748
1032
|
| `JD0050` | live queries require change capture |
|
|
749
1033
|
| `JD0051` | the demanded live mode is unavailable |
|
|
@@ -763,16 +1047,161 @@ error.
|
|
|
763
1047
|
| `JD2061` | another context owns the database |
|
|
764
1048
|
| `JD2062` | the store closed with job handlers still in flight |
|
|
765
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 |
|
|
1081
|
+
| `JD0060` | a replication envelope or snapshot is invalid |
|
|
1082
|
+
| `JD2100` | a replica sequence or causal dependency has a gap |
|
|
1083
|
+
| `JD2101` | an envelope identity names different content or an unknown local origin |
|
|
1084
|
+
| `JD2102` | the replica identity or model revision disagrees |
|
|
1085
|
+
| `JD2103` | a resolver fails its synchronous decision contract |
|
|
1086
|
+
| `JD2104` | a logical row or snapshot disagrees with its causal history |
|
|
1087
|
+
| `JD2105` | an explicit snapshot reset is required, or reset would discard acknowledged history |
|
|
1088
|
+
| `JD2106` | a replication operation or snapshot exceeds its configured bound |
|
|
766
1089
|
|
|
767
1090
|
The table above is proven in sync with the runtime `DB_CODES` table by
|
|
768
1091
|
a test.
|
|
769
1092
|
|
|
1093
|
+
**One classification of driver failures.** Every path that meets a
|
|
1094
|
+
driver error — a collection or entity write, the job queue, the query
|
|
1095
|
+
path, a maintenance operation, the backup, the open sequence —
|
|
1096
|
+
consults one table (`classifyDriverError`), so the same failure arrives
|
|
1097
|
+
under the same code with the same `class` and `retryable` verdict
|
|
1098
|
+
whichever path met it: `busy` (SQLITE_BUSY/LOCKED → `JD2005`,
|
|
1099
|
+
retryable), `full` (`JD2082`), `readonly` (`JD2083`), `io` (`JD2084`),
|
|
1100
|
+
`corrupt` (`JD2085`), `cantopen` and `constraint` (`JD2005`), `duplicate`
|
|
1101
|
+
(a UNIQUE collision on the key column → `JD2001`), `overflow` (a pushed
|
|
1102
|
+
integer aggregate past int64 — never raised: the query path re-runs the
|
|
1103
|
+
document in the engine and answers the double, and `explain().fallback`
|
|
1104
|
+
records it), and the fallback `error` (`JD2005`). A classified error
|
|
1105
|
+
carries `class`, `retryable` and the driver's error as `cause`; a
|
|
1106
|
+
lifecycle that owns its failure code (`JD2078` for maintenance and
|
|
1107
|
+
backup, `JD0002` at open) keeps the code and still carries the class.
|
|
1108
|
+
|
|
1109
|
+
The ENGINE is discriminated by the evidence the error itself carries,
|
|
1110
|
+
never by a table threaded down from the caller: a SQLite binding
|
|
1111
|
+
attaches a numeric result code, a PostgreSQL one attaches a
|
|
1112
|
+
five-character SQLSTATE. Both land in the classes above, and three
|
|
1113
|
+
conditions a single-writer file database does not have get their own:
|
|
1114
|
+
`connection` (`JD2087`, retryable — SQLSTATE class 08 and the
|
|
1115
|
+
administrator's own terminations), `aborted` (`JD2088` — SQLSTATE
|
|
1116
|
+
`25P02`, a statement issued after an earlier failure inside the same
|
|
1117
|
+
transaction) and `cancelled` (`JD2089` — SQLSTATE `57014`). A
|
|
1118
|
+
serialization failure or a deadlock (`40001`, `40P01`) is `busy` and
|
|
1119
|
+
retryable, which is the same verdict, and the same caller branch, a
|
|
1120
|
+
locked SQLite file gets.
|
|
1121
|
+
An engine error thrown inside a pushed user function (`JQ…`) is not a
|
|
1122
|
+
driver error: it passes through untouched, relocated onto the caller's
|
|
1123
|
+
document path (`/$where/…`, never the hatch's `/$return/…`).
|
|
1124
|
+
|
|
1125
|
+
## 7A. Model-declared index expressions
|
|
1126
|
+
|
|
1127
|
+
An index may name a computation instead of a member:
|
|
1128
|
+
|
|
1129
|
+
```json
|
|
1130
|
+
{
|
|
1131
|
+
"name": "by_lower_email",
|
|
1132
|
+
"expression": { "call": "lower", "args": [{ "member": "$.email" }] },
|
|
1133
|
+
"unique": true
|
|
1134
|
+
}
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
`expression` is mutually exclusive with `path` and with `derive`
|
|
1138
|
+
(`JD0004`): an expression names the members it reads itself, and a
|
|
1139
|
+
derived spatial column IS an expression this format spells for you.
|
|
1140
|
+
|
|
1141
|
+
**The vocabulary is closed — three node kinds and no fourth**, and none
|
|
1142
|
+
of them is SQL text:
|
|
1143
|
+
|
|
1144
|
+
| node | meaning |
|
|
1145
|
+
|---|---|
|
|
1146
|
+
| `{ "member": "$.a.b" }` | a singular JSONPath expression into the stored document |
|
|
1147
|
+
| `{ "value": 1 }` | a JSON string, number or boolean. A `null` or a compound has no place in an index expression |
|
|
1148
|
+
| `{ "call": "lower", "args": [ … ] }` | a function the HOST declared, applied to its arguments in order |
|
|
1149
|
+
|
|
1150
|
+
Argument order is significant: `sub(a, b)` and `sub(b, a)` are
|
|
1151
|
+
different expressions and different columns. An expression nests at
|
|
1152
|
+
most eight deep.
|
|
1153
|
+
|
|
1154
|
+
**A function is DECLARED by the host, never created by the store.**
|
|
1155
|
+
`openStore(model, { expressions })` takes one declaration per name:
|
|
1156
|
+
|
|
1157
|
+
```jsonc
|
|
1158
|
+
{
|
|
1159
|
+
"lower": {
|
|
1160
|
+
"arity": 1,
|
|
1161
|
+
"deterministic": true, // required, and never inferred
|
|
1162
|
+
"apply": (value) => …, // an engine that registers functions calls this
|
|
1163
|
+
"sql": "lower" // one that cannot calls this IMMUTABLE function
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
Why a declaration rather than an escape hatch: **an index over a
|
|
1169
|
+
function is a schema dependency.** A database whose column is computed
|
|
1170
|
+
by `lower(…)` cannot be written from a connection that has no `lower`,
|
|
1171
|
+
and a raw-SQL index would have had exactly that hazard with none of the
|
|
1172
|
+
checking. Here the model names the function, every store that opens the
|
|
1173
|
+
model is handed the same declaration, and a store that cannot honour
|
|
1174
|
+
one refuses at open — `JD0004`, before a single statement:
|
|
1175
|
+
|
|
1176
|
+
- a name this store was not given
|
|
1177
|
+
- an arity the expression does not match
|
|
1178
|
+
- a function not declared `deterministic`
|
|
1179
|
+
- no `apply` where the engine computes the value itself
|
|
1180
|
+
- no `sql` name where the engine calls its own — and that name is an
|
|
1181
|
+
identifier, never SQL text
|
|
1182
|
+
|
|
1183
|
+
**How each engine computes it.** SQLite registers `apply` as a
|
|
1184
|
+
deterministic function under a namespaced name (`jaren_x_lower`) — so a
|
|
1185
|
+
model's `lower` never shadows the engine's own — and the column is
|
|
1186
|
+
`GENERATED ALWAYS AS (jaren_x_lower(<member>)) VIRTUAL`. PostgreSQL
|
|
1187
|
+
registers nothing: the column is `GENERATED ALWAYS AS (lower(<member>))
|
|
1188
|
+
STORED` over the immutable function the host promised the server has.
|
|
1189
|
+
Both read the member as its own SCALAR, so `lower` of a string is the
|
|
1190
|
+
same answer on both.
|
|
1191
|
+
|
|
1192
|
+
One column serves every index that declares the same canonical
|
|
1193
|
+
expression, and the canonical form — order-preserving — is what the
|
|
1194
|
+
shape hash and the migration diff read. `store.introspect()` reads the
|
|
1195
|
+
expression back out of the SQL the dialect wrote (each dialect reads its
|
|
1196
|
+
own), so a declared expression index survives the round trip; without
|
|
1197
|
+
the declarations the column is REPORTED as unmapped rather than guessed.
|
|
1198
|
+
|
|
770
1199
|
## 8. The safe execution profile
|
|
771
1200
|
|
|
772
1201
|
A query document that arrives from a tenant, a remote client or a
|
|
773
1202
|
language model can reach a database. Parameter binding makes injection
|
|
774
1203
|
structurally impossible; it does nothing about resource exhaustion or
|
|
775
|
-
cross-tenant reads. A **profile** composes
|
|
1204
|
+
cross-tenant reads. A **profile** composes five independent bounds:
|
|
776
1205
|
|
|
777
1206
|
```js
|
|
778
1207
|
const store = await openStore(model, { driver, profile: 'safe' });
|
|
@@ -788,7 +1217,91 @@ so a store-level mandatory predicate or allow-list does not carry into
|
|
|
788
1217
|
per call, or set it once on the store and pass none. The defaults: engine limits
|
|
789
1218
|
`{ sequenceItems: 100000, resultItems: 10000, steps: 1000000, depth: 32 }`,
|
|
790
1219
|
`maxRows: 1000`, no externals, no host functions, no collations, all
|
|
791
|
-
of the store's collections, no mandatory predicates, no scan refusal
|
|
1220
|
+
of the store's collections, no mandatory predicates, no scan refusal,
|
|
1221
|
+
and no graph caps (`maxIncludedRows`, `maxDepth`, `maxBytes` all
|
|
1222
|
+
`null`), and no member allow-list (`members: null`).
|
|
1223
|
+
|
|
1224
|
+
**One profile, every engine.** A profile — the store's, or the call's
|
|
1225
|
+
through `ExecuteOptions.profile` on `execute`, `query`, `cursor`,
|
|
1226
|
+
`loadCursor`, `page` and `explain` alike — applies identically to
|
|
1227
|
+
collection execution, entity execution (`entity.execute`, the chain's
|
|
1228
|
+
cursor), graph loading (`load`, `loadCursor`, `page`, every include
|
|
1229
|
+
subquery) and store-root execution (`store.execute`). `collections` is
|
|
1230
|
+
the one allow-list and names collections AND entity roots; a document
|
|
1231
|
+
that reads a name outside it is `JD0011` on every engine, with the
|
|
1232
|
+
same reason. `predicates` is keyed by collection or entity name, and
|
|
1233
|
+
an entity's predicate is conjoined into every fetch of that entity —
|
|
1234
|
+
the native statement, each root the residual fetches, the load's root
|
|
1235
|
+
and every include subquery over that entity. `maxRows` bounds every
|
|
1236
|
+
fetch on every engine (`JD2007`), the residual's input rows included:
|
|
1237
|
+
each root an entity residual fetches carries `LIMIT maxRows + 1`. The
|
|
1238
|
+
graph caps are hard maxima an include's own declaration cannot exceed:
|
|
1239
|
+
`maxIncludedRows` refuses an include that declares more rows per root
|
|
1240
|
+
(or `Infinity`), `maxDepth` refuses a deeper load, and `maxBytes`
|
|
1241
|
+
refuses any one item — a document, an entity row, a loaded root graph —
|
|
1242
|
+
larger than that many serialised bytes (`JD2076`).
|
|
1243
|
+
|
|
1244
|
+
**The member allow-list.** `collections` says which roots a document
|
|
1245
|
+
may read; `members` says which MEMBERS of a root it may read. It is
|
|
1246
|
+
keyed by collection or entity name, and each value is a list in the
|
|
1247
|
+
model's own singular index-path spelling:
|
|
1248
|
+
|
|
1249
|
+
```js
|
|
1250
|
+
collection.query(doc, { profile: {
|
|
1251
|
+
members: { docs: ['$.id', '$.n'], User: ['$.id', '$.age'] } } });
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
A root the list does not name is unrestricted; a root the MODEL does
|
|
1255
|
+
not declare is `JD0011` before any statement runs, because a policy
|
|
1256
|
+
that applies to nothing is a policy failing open. Allowing a member
|
|
1257
|
+
allows everything UNDER it (`$.address` allows `$.address.city`) and
|
|
1258
|
+
none of its siblings (`$.address.city` does not allow `$.address`,
|
|
1259
|
+
which would answer the rest of the address). One collector reads every
|
|
1260
|
+
member path a document references — in `$where`, in `$orderby`, in
|
|
1261
|
+
`$return`, in a join condition, in a registered operator's operands —
|
|
1262
|
+
so a denial is the same `JD0011` wherever the member was named, and it
|
|
1263
|
+
names the root, the member and the place in the caller's document.
|
|
1264
|
+
|
|
1265
|
+
Reading a root item WHOLE is refused, not narrowed: `$return: '$it'`,
|
|
1266
|
+
an alias of the binding through `$let`, and a wildcard with no singular
|
|
1267
|
+
prefix each answer members the list does not allow, and no list can
|
|
1268
|
+
cover them. For the same reason a graph `load()`, `page()` or
|
|
1269
|
+
`loadCursor()` over a policed entity is refused — it answers whole
|
|
1270
|
+
documents by definition — and the refusal names the members that ARE
|
|
1271
|
+
allowed, which the document query engine can project.
|
|
1272
|
+
|
|
1273
|
+
**Enforced, or refused — never approximated.** A budget the engine
|
|
1274
|
+
can COUNT is enforced during execution: rows returned, rows
|
|
1275
|
+
materialised, residual-input rows, included rows per root, depth,
|
|
1276
|
+
bytes, and the engine limits. A budget SQLite cannot measure is
|
|
1277
|
+
refused at preflight on plan shape instead: `refuseFullScan` declines
|
|
1278
|
+
a native plan whose own `EXPLAIN QUERY PLAN` shows a full-table scan,
|
|
1279
|
+
and declines an entity residual outright, because the residual reads
|
|
1280
|
+
every row of every referenced root before the engine decides — that
|
|
1281
|
+
is a full-table scan by shape, so it is refused before any fetch
|
|
1282
|
+
rather than estimated. Visited-row and elapsed-time budgets exist
|
|
1283
|
+
only where a driver supplies a progress or interrupt hook; the
|
|
1284
|
+
shipped SQLite drivers supply neither, and `explain().budget` reports
|
|
1285
|
+
`time: 'unavailable'` and `estimatedRows: 'unavailable'` rather than
|
|
1286
|
+
a number nothing measured.
|
|
1287
|
+
|
|
1288
|
+
**`signal` and `deadline`.** `ExecuteOptions.signal` cancels: a call
|
|
1289
|
+
already aborted issues no statement (`JD2072`), and a cursor or page
|
|
1290
|
+
releases its statement at the next row boundary. `deadline` is an
|
|
1291
|
+
epoch-millisecond instant checked before a statement runs and at
|
|
1292
|
+
every row boundary of a cursor or page (`JD2075`) — a row-boundary
|
|
1293
|
+
check, never a statement interrupt, for the reason above. The clock it
|
|
1294
|
+
is checked against is the store's runtime record's `now`
|
|
1295
|
+
(`@jarenjs/core/runtime`; the platform clock with no record), so a
|
|
1296
|
+
caller under an injected clock computes deadlines from that clock.
|
|
1297
|
+
|
|
1298
|
+
**Provenance.** Every `explain()` — collection, entity, graph —
|
|
1299
|
+
carries `budget`: the profile that applied and from where (`{ source:
|
|
1300
|
+
'call' | 'store', name: 'safe' | 'custom' }`, or `null`), every bound
|
|
1301
|
+
it imposed (`rows`, `includedRows`, `depth`, `bytes`, `limits`), the
|
|
1302
|
+
scan verdict (`'refused-by-shape'` or `'unbounded'`), and the two
|
|
1303
|
+
driver slots by name (`time`, `estimatedRows`). A budget nobody can
|
|
1304
|
+
prove was applied is not a budget.
|
|
792
1305
|
|
|
793
1306
|
1. **Engine limits.** The four engine limits ride into every residual
|
|
794
1307
|
compilation, so the JavaScript portion of a query is bounded by the
|
|
@@ -956,6 +1469,38 @@ to build one on — a crude guess would be dishonest. It pushes
|
|
|
956
1469
|
deterministically and this profile is published so the shape of the win
|
|
957
1470
|
is known; add a narrowing predicate or a `LIMIT` and the push pays.
|
|
958
1471
|
|
|
1472
|
+
**`pushable: 'aggregate'` — the whole-sequence fold, in SQL.** A pack
|
|
1473
|
+
entry marked `'aggregate'` is lowered to a registered **SQL aggregate**
|
|
1474
|
+
where the driver has one (`capabilities.aggregateFunctions`): the plan
|
|
1475
|
+
emits `… SELECT jaren_a_<hash>(<the member's column>)`, SQLite drives
|
|
1476
|
+
the accumulation, and the SAME pure function the residual would call
|
|
1477
|
+
folds the values it collected. `$mean`, `$median`, `$variance` and
|
|
1478
|
+
`$stddev` of the statistics pack carry the token.
|
|
1479
|
+
|
|
1480
|
+
The token is a promise about the FOLD, and three rules enforce it:
|
|
1481
|
+
|
|
1482
|
+
- **order-insensitive.** A SQL aggregate visits rows in an order
|
|
1483
|
+
nothing specifies, so only a summary whose value depends on the
|
|
1484
|
+
multiset alone may be declared pushable.
|
|
1485
|
+
- **one sequence operand.** A SQL aggregate's final step sees only what
|
|
1486
|
+
the row steps accumulated, so a second operand does not reach a fold
|
|
1487
|
+
over zero rows. An entry declaring anything but a single
|
|
1488
|
+
`'seq<number>'` operand and a `'number'` result is a `TypeError` at
|
|
1489
|
+
`openStore`, naming the operator — loud, rather than a silent
|
|
1490
|
+
non-promotion. `$percentile`, whose second operand is the percentile,
|
|
1491
|
+
is therefore `pushable: false` and folds in the engine.
|
|
1492
|
+
- **a numeric member that cannot hold `null`.** SQL cannot tell a
|
|
1493
|
+
stored `null` from an absent member and the engine's sequence can, so
|
|
1494
|
+
the promotion needs the same schema-typed path the core `$sum` and
|
|
1495
|
+
`$avg` need. An absent member contributes nothing on either side.
|
|
1496
|
+
|
|
1497
|
+
A grouped fold is not promoted — a `$groupby` outside the fixed
|
|
1498
|
+
temporal bucket is engine work (§6) — and neither is an aggregate under
|
|
1499
|
+
a window. Both answer what the engine answers, and `explain()` names
|
|
1500
|
+
the reason. As with the scalar hatch, a profiled document triggers no
|
|
1501
|
+
registration: the same `$mean` under a profile folds in the residual,
|
|
1502
|
+
and `strict: true` refuses it by name (`JD0010`).
|
|
1503
|
+
|
|
959
1504
|
**The same profile for a spatial predicate.** A `$within` against a
|
|
960
1505
|
LITERAL region takes the hatch only on a collection that declares **no**
|
|
961
1506
|
derived spatial index on the member: where one is declared the
|
|
@@ -986,15 +1531,11 @@ it returns, which is faster than either column above.
|
|
|
986
1531
|
|
|
987
1532
|
**The honest ceiling.** A `pushable:false` operator (a whole-series
|
|
988
1533
|
`$npv`, an `$sma`) is never a UDF — it stays the residual, `explain()`
|
|
989
|
-
lists no `udfs` for it
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
pushdown additionally waits on `$groupby` pushdown, itself a deliberate
|
|
995
|
-
residual today. The `aggregateFunctions` capability is probed and
|
|
996
|
-
reported regardless, so the day a pack marks `'aggregate'` the driver
|
|
997
|
-
gate is already in place.
|
|
1534
|
+
lists no `udfs` for it, and neither does a `pushable:'aggregate'` entry
|
|
1535
|
+
whose shape the store refuses (above). A registered aggregate lowers
|
|
1536
|
+
only as the top-level fold of a whole selection: inside a `$groupby`'s
|
|
1537
|
+
`$return` the closed BUILT-IN set is what SQL groups by, and a
|
|
1538
|
+
registered summary there stays the engine's.
|
|
998
1539
|
|
|
999
1540
|
## 9. Entities, the `x-entity` vocabulary, relations
|
|
1000
1541
|
|
|
@@ -1173,10 +1714,13 @@ match — and every successful write bumps it (§11.5).
|
|
|
1173
1714
|
|
|
1174
1715
|
### 9.7 Error-code additions
|
|
1175
1716
|
|
|
1176
|
-
The entity engine adds
|
|
1717
|
+
The entity engine adds four codes to the package's single table (§7):
|
|
1177
1718
|
`JD0030` — an unknown or unread `x-entity` member; `JD0031` — relation
|
|
1178
1719
|
declarations whose inverses contradict; `JD0033` — an entity query
|
|
1179
|
-
document that binds no entity array (§10.1)
|
|
1720
|
+
document that binds no entity array (§10.1); `JD0034` — a cursor asked
|
|
1721
|
+
to track (`tracking: true`) over a document that yields no entity
|
|
1722
|
+
document to register: a projection, a count or a window (§10.1, the
|
|
1723
|
+
cursor). Everything else raises the
|
|
1180
1724
|
existing codes: `JD0005` for structural model defects (a key, index or
|
|
1181
1725
|
version property without a column of its own, a default on a relation,
|
|
1182
1726
|
`default: "auto"` off the key, a self-referencing many-to-many, a
|
|
@@ -1349,18 +1893,126 @@ Include depth is bounded (default 3, override with `maxDepth`);
|
|
|
1349
1893
|
exceeding it is `JD0032` with the bound printed. A cyclic include
|
|
1350
1894
|
specification is rejected. Unknown relation names are `JD0032` too.
|
|
1351
1895
|
|
|
1896
|
+
**Every include is bounded per root.** One root graph is the unit a
|
|
1897
|
+
graph cursor yields and a page counts, and "one root" is no bound at
|
|
1898
|
+
all if one root may aggregate a million related rows. So a to-many
|
|
1899
|
+
include carries `maxRows` — related rows per parent — and every
|
|
1900
|
+
row-projecting include carries `maxBytes` — serialised bytes of the
|
|
1901
|
+
relation per parent, measured on the JSON text the database projects.
|
|
1902
|
+
A relation that crosses either bound is the coded refusal **`JD2073`**
|
|
1903
|
+
naming the root (entity and key), the member and the bound that was
|
|
1904
|
+
hit — never a truncated graph, which a caller could not tell from a
|
|
1905
|
+
whole one. The refusal names the two supported alternatives: read
|
|
1906
|
+
`{ count: true }` when the size is the question, or page that relation
|
|
1907
|
+
separately. A bound always exists: an include with none declared
|
|
1908
|
+
inherits the store defaults, `INCLUDE_ROWS_DEFAULT` (1000 rows) and
|
|
1909
|
+
`INCLUDE_BYTES_DEFAULT` (1 MiB); an include with a `take` has that
|
|
1910
|
+
window as its row bound. The unbounded case is spelled, never
|
|
1911
|
+
inherited — `maxRows: Infinity` (`null` in the JSON spec) — so loading
|
|
1912
|
+
a relation whole is a decision rather than an oversight. The to-many
|
|
1913
|
+
subquery carries `LIMIT maxRows + 1`, so the bound is detected at the
|
|
1914
|
+
bound instead of after aggregating the whole relation; a `count`
|
|
1915
|
+
include is a number and carries neither. `explainLoad().bounds` lists
|
|
1916
|
+
the bounds every include ran under.
|
|
1917
|
+
|
|
1918
|
+
**The graph cursor.** `store.entity(name).loadCursor(spec, options)` —
|
|
1919
|
+
`graph.cursor(options)` on the client — yields one root graph per
|
|
1920
|
+
pull, its includes attached and bounded as above, from the same one
|
|
1921
|
+
statement `load` runs: the include rows ride inside each root row as
|
|
1922
|
+
the JSON the database projected, so the window is the row itself and
|
|
1923
|
+
there is no second statement per level to hold or release. `return()`
|
|
1924
|
+
releases the statement exactly once; `signal` cancels at a row boundary
|
|
1925
|
+
(`JD2072`). The cursor registers nothing with the unit of work unless
|
|
1926
|
+
`tracking: true` is spelled per call — a snapshot per yielded root is a
|
|
1927
|
+
tracker that grows with the result.
|
|
1928
|
+
|
|
1352
1929
|
### 10.5 Pagination
|
|
1353
1930
|
|
|
1354
1931
|
`$orderby` + `$subsequence` translate to `ORDER BY` + `LIMIT/OFFSET`
|
|
1355
|
-
on the query surface. On the `load` surface
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
(`
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1932
|
+
on the query surface. On the `load` surface `skip` compiles to offset,
|
|
1933
|
+
and `after` — a cursor — to **keyset pagination** (`WHERE …` over the
|
|
1934
|
+
last row's order keys) instead of a growing `OFFSET`; `explainLoad()`
|
|
1935
|
+
reports which strategy ran (`keyset` / `offset` / `none`) — offset
|
|
1936
|
+
degrading quietly on large tables is a well-known footgun, and naming
|
|
1937
|
+
it is cheap.
|
|
1938
|
+
|
|
1939
|
+
**The single-column form.** `after: <value>` with a single ascending or
|
|
1940
|
+
descending ordering over a UNIQUE column — the key, or any `unique:
|
|
1941
|
+
true` column — compiles to `WHERE col > ?` / `< ?`. A scalar cursor
|
|
1942
|
+
over a non-unique column, a document path, or a multi-key ordering is
|
|
1943
|
+
refused (`JD0032`), because such a cursor either skips rows or repeats
|
|
1944
|
+
them wherever the value ties.
|
|
1945
|
+
|
|
1946
|
+
**The composite keyset.** The orderings a list actually wants —
|
|
1947
|
+
`(updatedAt, id)`, `(createdAt, id)`, `(priority desc, id)` — have a
|
|
1948
|
+
non-unique first column. `page()` and a structural `after` compile them
|
|
1949
|
+
as the lexicographic expansion over the declared terms
|
|
1950
|
+
`(k1 dir1, k2 dir2, …)` with **the primary key appended** as the
|
|
1951
|
+
tie-breaker whether or not the caller named it (it is the one column
|
|
1952
|
+
guaranteed unique, and a tie on every declared key would otherwise be a
|
|
1953
|
+
skipped row or a repeated one):
|
|
1954
|
+
|
|
1955
|
+
```sql
|
|
1956
|
+
(k1 > v1) OR (k1 = v1 AND k2 > v2) OR (k1 = v1 AND k2 = v2 AND pk > vpk)
|
|
1957
|
+
```
|
|
1958
|
+
|
|
1959
|
+
with `<` for a descending term, and the `ORDER BY` ending in the key
|
|
1960
|
+
column(s) rather than the row identity. **Null placement agrees with
|
|
1961
|
+
the plan**: every term's null order (`NULLS FIRST`/`LAST`, from `$dir`
|
|
1962
|
+
and `$empty`) is spelled in the expansion too — after a null value come
|
|
1963
|
+
the non-nulls when nulls sort first and nothing when they sort last;
|
|
1964
|
+
after a non-null value come the greater (or lesser) values and, when
|
|
1965
|
+
nulls sort last, the nulls — because SQL's `col > ?` is neither true
|
|
1966
|
+
nor false for `NULL`, and a comparison alone would visit a null-keyed
|
|
1967
|
+
row twice or never. Only mapped columns carry a keyset; a document path
|
|
1968
|
+
in the ordering is refused (`JD0032`).
|
|
1969
|
+
|
|
1970
|
+
**The continuation** a page emits is unsigned, structural and opaque:
|
|
1971
|
+
|
|
1972
|
+
```jsonc
|
|
1973
|
+
{ "order": [{ "column": "updatedAt", "desc": true, "nullsFirst": false },
|
|
1974
|
+
{ "column": "id", "desc": false, "nullsFirst": false }],
|
|
1975
|
+
"keys": [v1], // the declared order-key values, as the document carries them
|
|
1976
|
+
"key": vpk } // the row's primary key (a record for a composite key)
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
`order` is the ordering's identity: a continuation replayed against a
|
|
1980
|
+
different ordering is the refusal `JD0035`, never a wrong page; a
|
|
1981
|
+
continuation whose `keys` do not match the declared key count, or
|
|
1982
|
+
whose `key` is not the entity's key shape, is `JD0035` too. Signing,
|
|
1983
|
+
tenant scoping, expiry and wire encoding are the **host's**: the store
|
|
1984
|
+
has no principal and no key, so any signature it invented would be
|
|
1985
|
+
security theatre — a host that ships a continuation to an untrusted
|
|
1986
|
+
client signs it first.
|
|
1987
|
+
|
|
1988
|
+
**The page.** `store.entity(name).page(spec, { limit, after, maxBytes,
|
|
1989
|
+
consistency, signal })` — `graph.page(options)` on the client — drains
|
|
1990
|
+
the graph cursor in keyset mode and answers `{ items, continuation,
|
|
1991
|
+
hasMore, snapshot }`: never more than `limit` roots (default
|
|
1992
|
+
`PAGE_LIMIT_DEFAULT`, 100) nor more than `maxBytes` serialised bytes
|
|
1993
|
+
(none unless given; every root is bounded by §10.4 regardless), the
|
|
1994
|
+
continuation of the last delivered root, and `hasMore` decided by one
|
|
1995
|
+
peek past the page. A `take` or `skip` in the spec beside `page()` is
|
|
1996
|
+
refused (`JD0032`): the page windows by its limit. **The
|
|
1997
|
+
`item_too_large` rule**: an item that alone exceeds `maxBytes` when
|
|
1998
|
+
nothing has been delivered yet is the coded refusal `JD2074`, raised
|
|
1999
|
+
without advancing the continuation — a caller that retries meets the
|
|
2000
|
+
same refusal, which is the honest answer, never a loop and never a
|
|
2001
|
+
silent breach; an item that does not fit beside earlier ones ends the
|
|
2002
|
+
page before it (`hasMore: true`). A page registers no snapshots unless
|
|
2003
|
+
`tracking: true` is spelled.
|
|
2004
|
+
|
|
2005
|
+
**Snapshot versus live.** A page reports `snapshot: true` only when
|
|
2006
|
+
every order key is immutable — and the primary key is the one column
|
|
2007
|
+
the engine itself guarantees never moves (`update()` refuses to rewrite
|
|
2008
|
+
it). Ordering a live table by a mutable column such as `updatedAt` is
|
|
2009
|
+
**live pagination**, and the page says so (`snapshot: false`): later
|
|
2010
|
+
inserts land where their keys sort, but an existing row whose order
|
|
2011
|
+
key changes between two pages can move across the cursor — it may be
|
|
2012
|
+
seen twice, or not at all — and no cutoff on later writes prevents
|
|
2013
|
+
that. `consistency: 'snapshot'` over such an ordering is refused
|
|
2014
|
+
(`JD0036`) rather than mislabelled; the default `'live'` reports the
|
|
2015
|
+
truth either way. A caller who needs a snapshot orders by the key.
|
|
1364
2016
|
|
|
1365
2017
|
### 10.6 What remains residual
|
|
1366
2018
|
|
|
@@ -1369,13 +2021,17 @@ because joins make residuals more expensive — accompanied by the
|
|
|
1369
2021
|
`EXPLAIN QUERY PLAN` narrative (SQLite exposes no row estimates;
|
|
1370
2022
|
a number appears only where `capabilities.rowEstimates` is filled):
|
|
1371
2023
|
|
|
1372
|
-
-
|
|
2024
|
+
- a binding nothing joins to — the cartesian product a nested-loop plan
|
|
2025
|
+
must never emit by accident; every binding past the first attaches by
|
|
2026
|
+
a column equality to one already joined, and a graph that does not
|
|
2027
|
+
close is the engine's;
|
|
1373
2028
|
- non-equality join predicates, and disjunctions spanning bindings;
|
|
1374
|
-
- `$groupby
|
|
1375
|
-
(
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
- projections (`$return` objects) — over one binding
|
|
2029
|
+
- a `$groupby` whose key is untyped or admits `null`, whose `$return`
|
|
2030
|
+
reads the binding (after a grouping it holds the group's ROWS), or
|
|
2031
|
+
whose `$orderby` names anything but a group key; a window over the
|
|
2032
|
+
groups, or an aggregate of them;
|
|
2033
|
+
- projections (`$return` objects) ACROSS a join — over one binding a
|
|
2034
|
+
nested shape of member paths lowers (§ the projection tree);
|
|
1379
2035
|
- externals against document paths; booleans and `null` at bind time;
|
|
1380
2036
|
- everything phase A already listed (§8 of `QUERY-FORMAT.md`
|
|
1381
2037
|
notwithstanding, the truth table is the contract).
|
|
@@ -1398,6 +2054,29 @@ arithmetic:
|
|
|
1398
2054
|
an aggregate over a path that admits `null`, or over a boolean path,
|
|
1399
2055
|
is a named residual, so the two paths keep answering alike.
|
|
1400
2056
|
|
|
2057
|
+
### 10.7 The join table as a query root
|
|
2058
|
+
|
|
2059
|
+
A declared many-to-many join table is a **read-only query root**:
|
|
2060
|
+
`$.<JoinTable>[*]` binds like any entity array and answers rows carrying
|
|
2061
|
+
exactly its two key columns — `<A>_key` and `<B>_key`, the names the DDL
|
|
2062
|
+
creates — and nothing else, because a join row has no document of its
|
|
2063
|
+
own. It joins to the entities it relates like any other binding, so
|
|
2064
|
+
`Person → Person_Tag → Tag` is one statement over three roots.
|
|
2065
|
+
|
|
2066
|
+
It is a ROOT and not an entity: `store.entity('<JoinTable>')` is
|
|
2067
|
+
`JD2004`, memberships are still written through `link`/`unlink` and the
|
|
2068
|
+
unit of work, and nothing about the table's lifecycle changes. The two
|
|
2069
|
+
namespaces are one, so a join table whose name is also a declared
|
|
2070
|
+
entity's is refused at open (`JD0005`) — `$.X[*]` may mean one thing.
|
|
2071
|
+
|
|
2072
|
+
Because the root exists, a many-to-many **hop** lowers: a chain's
|
|
2073
|
+
`u.labels` becomes two links — the join row that names the membership,
|
|
2074
|
+
then the target row it names — instead of the `JL0105` refusal it was.
|
|
2075
|
+
A relation entry that does not name its join row's columns
|
|
2076
|
+
(`{ joinTable, ownColumn, ownKey, targetColumn, targetKey }` — §10.1)
|
|
2077
|
+
still refuses with that code, because there is then nothing to lower
|
|
2078
|
+
through.
|
|
2079
|
+
|
|
1401
2080
|
|
|
1402
2081
|
## 11. The unit of work
|
|
1403
2082
|
|
|
@@ -1514,8 +2193,14 @@ zero rows.) An unguarded delete of a missing row is a no-op.
|
|
|
1514
2193
|
`saveChanges()` is all-or-nothing inside one transaction. On ANY
|
|
1515
2194
|
failure the tracker is left exactly as it was before the call — the
|
|
1516
2195
|
same save can be retried once the cause is gone; a half-applied
|
|
1517
|
-
tracker is worse than a rollback.
|
|
1518
|
-
snapshots (bumped versions, generated keys) and clears pending
|
|
2196
|
+
tracker is worse than a rollback. A save whose statements all succeed
|
|
2197
|
+
advances snapshots (bumped versions, generated keys) and clears pending
|
|
2198
|
+
work **immediately**, even inside an enclosing transaction — inside it
|
|
2199
|
+
the database does hold those rows, and every later read, plan and
|
|
2200
|
+
optimistic guard must agree. The scope that owns the connection holds
|
|
2201
|
+
the exact undo delta: an enclosing rollback (or a named-savepoint
|
|
2202
|
+
rollback past the save, §5.2) withdraws the advance, a nested rollback
|
|
2203
|
+
withdraws only its own effects, and outer commit keeps it.
|
|
1519
2204
|
|
|
1520
2205
|
The return value is data, not a boolean:
|
|
1521
2206
|
|
|
@@ -1569,3 +2254,6 @@ const report = await store.saveChanges(); // { joinInserted: 1, joinDeleted
|
|
|
1569
2254
|
- The report counts the rows written under `joinInserted`/`joinDeleted`
|
|
1570
2255
|
and `stats().tracker.pendingMemberships` counts the pending
|
|
1571
2256
|
(entity, key, member) records.
|
|
2257
|
+
|
|
2258
|
+
Worker/pool options, synchronous cursors, wasm session probing and the browser
|
|
2259
|
+
persistence ladder are specified in [execution hosts](HOSTS.md).
|