@jarenjs/linq 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 +10 -0
- package/README.md +93 -2
- package/docs/APP-PEN.md +3 -3
- package/docs/CONTRACT-PEN.md +10 -6
- package/docs/DB-CLIENT.md +98 -19
- package/docs/FLOW-PEN.md +12 -5
- package/docs/FORMS-PEN.md +2 -2
- package/docs/JSLT-PEN.md +4 -4
- package/docs/LINQ-FORMAT.md +42 -34
- package/docs/MIGRATION-PEN.md +2 -2
- package/docs/MODEL-PEN.md +15 -6
- package/docs/QUERY-PEN.md +107 -19
- package/docs/SCHEMA-PEN.md +2 -2
- package/package.json +6 -6
- package/src/app/action.js +4 -8
- package/src/app/define.js +8 -13
- package/src/async.js +58 -10
- package/src/concurrency.js +40 -8
- package/src/contract/define.js +23 -10
- package/src/contract/index.js +5 -5
- package/src/contract/operation.js +10 -14
- package/src/db/handle.js +3 -0
- package/src/db/include.js +40 -5
- package/src/db/index.js +6 -0
- package/src/db/ledger.js +195 -0
- package/src/db/open.js +59 -11
- package/src/db/replication.js +20 -0
- package/src/errors.js +10 -1
- package/src/expression.js +30 -4
- package/src/federate.js +531 -0
- package/src/flow/dag.js +28 -14
- package/src/flow/fsm.js +6 -11
- package/src/index.js +1 -0
- package/src/jslt/rules.js +7 -12
- package/src/migration/define.js +9 -14
- package/src/migration/steps.js +5 -9
- package/src/model/collection.js +102 -0
- package/src/model/index.js +1 -1
- package/types/contract.d.ts +115 -18
- package/types/db.d.ts +189 -11
- package/types/index.d.ts +65 -0
- package/types/model.d.ts +34 -1
package/ARCHITECTURE.md
CHANGED
|
@@ -198,6 +198,16 @@ residual runs locally, and `explain()` reports the split.
|
|
|
198
198
|
(`JL0005`) rather than silently materialised — except two providers
|
|
199
199
|
sharing a `scope` (one store's entity sets), whose roots are two
|
|
200
200
|
bindings of one multi-entity input.
|
|
201
|
+
- **Federation is a door, not a default.** `federate({ sources,
|
|
202
|
+
maxRows, maxBytes })` (`src/federate.js`, QUERY-PEN §12.1) hands back
|
|
203
|
+
one provider source per name, sharing one scope, so the ordinary join
|
|
204
|
+
is admitted and the federation executes it: each side's own packed
|
|
205
|
+
document at its own source, the smaller side into a bounded hash
|
|
206
|
+
table, the other probed against it, and the caller's document decided
|
|
207
|
+
in the engine over the two reduced sets. It spells no join semantics
|
|
208
|
+
of its own — the reduction bounds the FETCH, and a value it cannot
|
|
209
|
+
key is kept rather than guessed at — and a budget is a refusal
|
|
210
|
+
(`JL2008`), never a spill.
|
|
201
211
|
- **The engine result shape leaks nowhere.** Every surface — sync,
|
|
202
212
|
async, provider — reproduces `undefined | item | items` exactly,
|
|
203
213
|
which is why the window-wrapper trick exists at all.
|
package/README.md
CHANGED
|
@@ -409,6 +409,10 @@ const api = typedClient(openHttpClient(compileContract(shop.document), { baseUrl
|
|
|
409
409
|
const outcome = await api.invoke('product.save', { id: 1, product }); // Outcome<Product>: ok | conflict, both typed
|
|
410
410
|
```
|
|
411
411
|
|
|
412
|
+
An HTTP client takes `typedHttpClient` instead: the same typed client
|
|
413
|
+
plus `bytes` over the opaque operations (`OpaqueOf<typeof shop>`), whose
|
|
414
|
+
success is a live response stream rather than a JSON value.
|
|
415
|
+
|
|
412
416
|
How to read it: an operation is a kind (`read`, `command`, `subscribe`),
|
|
413
417
|
its input and output schemas by the schema pen, its named errors and its
|
|
414
418
|
binding. A `named()` schema is hoisted into the contract's own `$defs`
|
|
@@ -569,14 +573,80 @@ const users = await db.entities.User
|
|
|
569
573
|
db.entities.User.link(users[0], 'labels', 'admin'); // 'labels' only: the many-to-many members
|
|
570
574
|
await db.saveChanges(); // the unit of work: get, mutate, save
|
|
571
575
|
const live = await db.live(db.entities.Post.where((p) => p.stars.ge(3))); // { result, subscribe, close, mode }
|
|
576
|
+
|
|
577
|
+
await db.transaction(async (tx) => { // a client of its own, inside the transaction
|
|
578
|
+
tx.entities.Post.add({ title: 'draft', stars: 0, authorId: users[0].id });
|
|
579
|
+
await tx.saveChanges(); // its own unit of work: nobody else sees it
|
|
580
|
+
});
|
|
572
581
|
```
|
|
573
582
|
|
|
583
|
+
`createDbLedger(db)` is the third export: the `@jarenjs/contract`
|
|
584
|
+
idempotency ledger over a declared collection of the store the client
|
|
585
|
+
opened — root claims under an immediate transaction so two processes
|
|
586
|
+
see one `new`, a transaction client's settlements inside the host's own
|
|
587
|
+
transaction, a persisted generation fence (`JL2007` for a stale ref) —
|
|
588
|
+
with no edge from the contract package to a store
|
|
589
|
+
([DB-CLIENT §2.6](docs/DB-CLIENT.md#26-the-ledger)).
|
|
590
|
+
|
|
574
591
|
How to read it: `db.entities.Post` is a chain root typed from the model,
|
|
575
592
|
so `p.stars` is a number in the editor and `p.author.email` is a
|
|
576
593
|
declared hop; `include` is a typed `load` spec and answers a two-level
|
|
577
594
|
graph in ONE statement; a write is the unit of work — get an entity,
|
|
578
595
|
mutate it, `saveChanges()` — and `live` re-answers a chain when the
|
|
579
|
-
store changes.
|
|
596
|
+
store changes.
|
|
597
|
+
|
|
598
|
+
Every large read is a real cursor or a bounded page. A `for await`
|
|
599
|
+
over a chain pulls one row per item from an open statement and a
|
|
600
|
+
`break` releases it (three rows of twenty thousand cost three rows);
|
|
601
|
+
`graph.cursor()` yields one root graph per pull with its includes
|
|
602
|
+
attached, every include bounded per root (`maxRows`, `maxBytes`, a
|
|
603
|
+
coded refusal — never a truncated graph); and `graph.page({ limit,
|
|
604
|
+
after, maxBytes })` — `db.entities.Post.graph()` opens a graph with
|
|
605
|
+
nothing included — pages over a composite keyset with the primary key
|
|
606
|
+
appended, answering `{ items, continuation, hasMore, snapshot }`
|
|
607
|
+
whose continuation is typed by the declared `orderBy`/`thenBy` and is
|
|
608
|
+
unsigned and structural: the host signs it. The store's `explain()`
|
|
609
|
+
says what each read will do (`streaming`, `barrier`, `budget`) before
|
|
610
|
+
it runs:
|
|
611
|
+
|
|
612
|
+
```js
|
|
613
|
+
for await (const post of db.entities.Post.where((p) => p.stars.ge(3))) { // one row per pull
|
|
614
|
+
if (post.stars > 100) break; // the statement is released here
|
|
615
|
+
}
|
|
616
|
+
const page = await db.entities.Post.graph()
|
|
617
|
+
.orderByDescending((p) => p.stars).thenBy((p) => p.pid)
|
|
618
|
+
.page({ limit: 20, maxBytes: 65536 }); // { items, continuation, hasMore, snapshot: false }
|
|
619
|
+
const next = await db.entities.Post.graph()
|
|
620
|
+
.orderByDescending((p) => p.stars).thenBy((p) => p.pid)
|
|
621
|
+
.page({ limit: 20, after: page.continuation }); // the same ordering, or JD0035
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`transaction` hands its callback a client of the same shape, over the
|
|
625
|
+
store INSIDE the transaction, with a unit of work of its own — so two
|
|
626
|
+
request handlers on one client hold two records for the same entity key
|
|
627
|
+
and neither sees the other's pending state. The outer `db.entities.X` is
|
|
628
|
+
by construction an unrelated caller: from inside, it waits for the commit
|
|
629
|
+
and then names itself `JD0012` rather than joining a transaction it is
|
|
630
|
+
not part of. One client is safe for a handler per request. The
|
|
631
|
+
transaction client is pinned to its EXACT scope — a handle kept past its
|
|
632
|
+
callback refuses `JD2070` instead of following a later transaction — and
|
|
633
|
+
forwards the store's `tx.savepoints` (MODEL-FORMAT §5.2), so a callback
|
|
634
|
+
can create, roll back to and release a named checkpoint mid-transaction
|
|
635
|
+
without throwing for control flow:
|
|
636
|
+
|
|
637
|
+
```js
|
|
638
|
+
await db.transaction(async (tx) => {
|
|
639
|
+
await tx.savepoints.create('before-optional-import');
|
|
640
|
+
tx.entities.Post.add({ title: 'optional', stars: 0, authorId: users[0].id });
|
|
641
|
+
const report = await tx.saveChanges(); // landed inside the transaction
|
|
642
|
+
if (report.fallbacks > 0) { // …until the caller changes its mind
|
|
643
|
+
await tx.savepoints.rollbackTo('before-optional-import'); // the add is pending again
|
|
644
|
+
}
|
|
645
|
+
await tx.savepoints.release('before-optional-import'); // the checkpoint is spent; the rest commits
|
|
646
|
+
});
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
This subpath is the package's one runtime edge: it
|
|
580
650
|
imports `@jarenjs/db`, `@jarenjs/validate` and `@jarenjs/formats` as
|
|
581
651
|
OPTIONAL peer dependencies, so `npm install @jarenjs/linq` alone
|
|
582
652
|
installs nothing new and the `.` entry carries not one byte of them
|
|
@@ -590,7 +660,7 @@ against Prisma, Drizzle and Kysely — is
|
|
|
590
660
|
A pen builds a **definition** — once, at module load — and the engine
|
|
591
661
|
compiles the document it emitted. That is the only place its price is
|
|
592
662
|
paid, and `benchmark/db.js` measures it as ns per build beside the
|
|
593
|
-
hand-written literal each pen must emit byte for byte — <!--fact:linq.penBuildCost-->schema
|
|
663
|
+
hand-written literal each pen must emit byte for byte — <!--fact:linq.penBuildCost-->schema 55.7×, model 89.4×, JSLT 61.2× a hand-written literal, and the migration pen 1.4× a hand-written document carrying the same two shape hashes<!--/fact-->.
|
|
594
664
|
|
|
595
665
|
Multiples that size are what typed builders, `$defs` hoisting, a
|
|
596
666
|
deep-freeze and a coded refusal per mistake cost against typing the JSON
|
|
@@ -626,3 +696,24 @@ you don't want this package.
|
|
|
626
696
|
The normative mapping — every operator, its emitted phrase, and the
|
|
627
697
|
deliberate deviations — is [docs/QUERY-PEN.md](docs/QUERY-PEN.md);
|
|
628
698
|
internals are in [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
699
|
+
|
|
700
|
+
## Exports
|
|
701
|
+
|
|
702
|
+
Every subpath a consumer can import, derived from the manifest by
|
|
703
|
+
`npm run docs:derive` (`npm run docs:check` fails when the two drift):
|
|
704
|
+
|
|
705
|
+
<!--fact:exports.linq-->
|
|
706
|
+
| Import | Kind | Declarations |
|
|
707
|
+
|---|---|---|
|
|
708
|
+
| `@jarenjs/linq` | JavaScript | declared |
|
|
709
|
+
| `@jarenjs/linq/schema` | JavaScript | declared |
|
|
710
|
+
| `@jarenjs/linq/model` | JavaScript | declared |
|
|
711
|
+
| `@jarenjs/linq/jslt` | JavaScript | declared |
|
|
712
|
+
| `@jarenjs/linq/migration` | JavaScript | declared |
|
|
713
|
+
| `@jarenjs/linq/contract` | JavaScript | declared |
|
|
714
|
+
| `@jarenjs/linq/flow` | JavaScript | declared |
|
|
715
|
+
| `@jarenjs/linq/app` | JavaScript | declared |
|
|
716
|
+
| `@jarenjs/linq/forms` | JavaScript | declared |
|
|
717
|
+
| `@jarenjs/linq/db` | JavaScript | declared |
|
|
718
|
+
| `@jarenjs/linq/package.json` | metadata | — |
|
|
719
|
+
<!--/fact-->
|
package/docs/APP-PEN.md
CHANGED
|
@@ -1123,7 +1123,7 @@ not look for them:
|
|
|
1123
1123
|
|
|
1124
1124
|
## 7. Cost
|
|
1125
1125
|
|
|
1126
|
-
`@jarenjs/linq/app` builds to **<!--fact:bundle.app-->
|
|
1126
|
+
`@jarenjs/linq/app` builds to **<!--fact:bundle.app-->47,444<!--/fact--> bytes** as a minified,
|
|
1127
1127
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
1128
1128
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
1129
1129
|
beside the other nine subpath prices in
|
|
@@ -1133,8 +1133,8 @@ pen and the JSLT pen (state, and views), and no chain module, no
|
|
|
1133
1133
|
|
|
1134
1134
|
It is the second-largest pen bundle after the client, and the two pens
|
|
1135
1135
|
it carries are most of it. The three figures the same probe measures,
|
|
1136
|
-
side by side: `@jarenjs/linq/schema` <!--fact:bundle.schema-->
|
|
1137
|
-
`@jarenjs/linq/jslt` <!--fact:bundle.jslt-->19,
|
|
1136
|
+
side by side: `@jarenjs/linq/schema` <!--fact:bundle.schema-->33,156<!--/fact--> bytes,
|
|
1137
|
+
`@jarenjs/linq/jslt` <!--fact:bundle.jslt-->19,856<!--/fact-->, `@jarenjs/linq/app` <!--fact:bundle.app-->47,444<!--/fact-->. The subpath sums do not add — all
|
|
1138
1138
|
three carry the capture, the expression lowering and the JSON boundary,
|
|
1139
1139
|
which each bundle counts once — so what the app pen costs a consumer who
|
|
1140
1140
|
already imports the schema pen is the difference the numbers do state:
|
package/docs/CONTRACT-PEN.md
CHANGED
|
@@ -239,11 +239,12 @@ they exist so a mismatch is a compile error rather than a 404.
|
|
|
239
239
|
| Method | Emits | Type reading | Status |
|
|
240
240
|
|---|---|---|---|
|
|
241
241
|
| `typedClient(client, contract)` | — (identity) | `TypedClient<C>`: `invoke` over the invokable operations, `subscribe` over the subscribe ones, `url` over all of them | native |
|
|
242
|
-
| `
|
|
242
|
+
| `typedHttpClient(client, contract)` | — (identity) | `TypedHttpClient<C>`: `TypedClient<C>` plus `bytes` over `OpaqueOf<C>` — the opaque operations, whose success is a `ByteResponse` (a live stream) rather than the output type; for an `openHttpClient` client only, a local or port client has no `bytes` | native |
|
|
243
|
+
| `typedHandlers(contract, handlers)` | — (identity) | `TypedHandlerTable<C, Host = null, Carrier = 'http'>`: one handler per invokable operation, `(input, ctx) => output \| Failure`; `ctx` is `HandlerContext<Host, Carrier>` — the HTTP context by default, `Host` the host lifecycle's `ctx.host`, a carrier union a discriminated union to narrow on `ctx.carrier` (CONTRACT-FORMAT §7.7) | native; a missing or misspelled operation does not compile, and an HTTP-only member on a port/local context does not either |
|
|
243
244
|
| `typedTools(tools, contract)` | — (identity) | `TypedTool<C>[]`: `name` is the id with `.` → `_`, `execute` takes the operation's ACCEPTED input | native |
|
|
244
245
|
|
|
245
|
-
All
|
|
246
|
-
wrap nothing and cost nothing. What they do is carry the phantom `Ops`
|
|
246
|
+
All of them are `void contract; return x;` at run time — they add
|
|
247
|
+
nothing, wrap nothing and cost nothing. What they do is carry the phantom `Ops`
|
|
247
248
|
onto a value the engine produced, which is what makes one authored
|
|
248
249
|
document type a client, a server's handler table and an AI toolbox with
|
|
249
250
|
no generate step. §3.6 runs all three over one contract and §5 is what
|
|
@@ -1017,8 +1018,11 @@ const handlers = typedHandlers(shop, {
|
|
|
1017
1018
|
const served = openLocalClient(compiled, handlers); // the binding: any contract client
|
|
1018
1019
|
const api = typedClient(served, shop); // the same object, narrowed by the phantom
|
|
1019
1020
|
const outcome: Outcome<Product> = await api.invoke('product.save', { id: 1, revision: 4, product });
|
|
1020
|
-
api.url('image.bytes', { id: 3 }); // an opaque operation: a URL builder
|
|
1021
|
+
api.url('image.bytes', { id: 3 }); // an opaque operation: a URL builder here
|
|
1021
1022
|
api.invoke('image.bytes', { id: 3 }); // does not compile — it carries bytes
|
|
1023
|
+
const web = typedHttpClient(openHttpClient(compiled, { baseUrl }), shop);
|
|
1024
|
+
const image = await web.bytes('image.bytes', { id: 3 }); // Outcome<ByteResponse>: { status, headers, media, body: ReadableStream }
|
|
1025
|
+
web.bytes('product.save', { id: 1, revision: 4, product }); // does not compile — a JSON operation is invoked, not streamed
|
|
1022
1026
|
for (const tool of typedTools(contractTools(compiled, api), shop)) toolbox.add(tool);
|
|
1023
1027
|
|
|
1024
1028
|
declare const anyContract: Contract<any>; // the class: an annotation, never a `new`
|
|
@@ -1184,7 +1188,7 @@ to write, and reaching them means one import of `@jarenjs/contract` over
|
|
|
1184
1188
|
|
|
1185
1189
|
## 7. Cost
|
|
1186
1190
|
|
|
1187
|
-
`@jarenjs/linq/contract` builds to **<!--fact:bundle.contract-->
|
|
1191
|
+
`@jarenjs/linq/contract` builds to **<!--fact:bundle.contract-->45,298<!--/fact--> bytes** as a minified,
|
|
1188
1192
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
1189
1193
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
1190
1194
|
(<!--fact:bundle.contract.kb-->45<!--/fact--> kB) beside the other nine subpath prices in
|
|
@@ -1196,7 +1200,7 @@ them:
|
|
|
1196
1200
|
|
|
1197
1201
|
- **the schema pen is included, and that is the ceiling.** A contract's
|
|
1198
1202
|
inputs and outputs are schemas, so the two are measured together and
|
|
1199
|
-
the bundle carries <!--fact:bundle.schema-->
|
|
1203
|
+
the bundle carries <!--fact:bundle.schema-->33,156<!--/fact--> of its <!--fact:bundle.contract-->45,298<!--/fact--> bytes as the schema pen's own.
|
|
1200
1204
|
The contract pen's own share is the remaining ~12 kB, most of it the
|
|
1201
1205
|
refusal messages §4 lists;
|
|
1202
1206
|
- **no chain module** — none of `sequence.js`, `document.js`, `async.js`,
|
package/docs/DB-CLIENT.md
CHANGED
|
@@ -83,10 +83,9 @@ invent one. §2 keeps its D3 slot and its meaning — this is where every
|
|
|
83
83
|
name a caller writes is named — under the title that describes what it
|
|
84
84
|
holds.
|
|
85
85
|
|
|
86
|
-
The
|
|
87
|
-
then whatever those two hand back: a client of frozen handles, each of
|
|
86
|
+
The exports include the store door and its helpers. The door hands back a client of frozen handles, each of
|
|
88
87
|
which is the store's own set plus the chain plus three additions. §2.1
|
|
89
|
-
divides the
|
|
88
|
+
divides the responsibilities; §2.2 to §2.7 enumerate the surface.
|
|
90
89
|
|
|
91
90
|
### 2.1 What is the store's and what is the client's
|
|
92
91
|
|
|
@@ -105,15 +104,17 @@ decides which document answers a question about behaviour.
|
|
|
105
104
|
| `client.collections.<name>` | the store's collection | the same chain start and `live`, typed from the pen's collection schema (§2.5) |
|
|
106
105
|
| `saveChanges()`, `transaction(fn)`, `close()`, `capabilities`, `store` | the store's | pass-throughs; `saveChanges` and `live` exist exactly when the model declares entities, as on the store; `store` is the escape hatch, typed `TypedStore` |
|
|
107
106
|
|
|
108
|
-
### 2.2 The
|
|
107
|
+
### 2.2 The exported names
|
|
109
108
|
|
|
110
|
-
The whole export surface: a door,
|
|
111
|
-
|
|
109
|
+
The whole export surface: a door, a type-level reader for what it hands
|
|
110
|
+
back, the durable ledger over what it opened, and replication document authoring.
|
|
112
111
|
|
|
113
112
|
| Name | Answers | Type reading |
|
|
114
113
|
|---|---|---|
|
|
115
114
|
| `open(model, options)` | a promise of the frozen client — `store`, `capabilities`, `entities`, `collections`, `transaction`, `close`, and `saveChanges`/`live` when the model declares entities | `Client<InferMeta<typeof model>>` for a pen model; `Client<E>` for `open<E>(json, …)`; the wide map for a bare JSON model |
|
|
115
|
+
| `defineReplication(header)` | a logical replication document builder — §2.7 | `ReplicationPen` |
|
|
116
116
|
| `defaultValidator()` | `new JarenValidator({ collectErrors: true })` with `stringFormats` and `dateTimeFormats` registered | `JarenValidator` |
|
|
117
|
+
| `createDbLedger(client, options?)` | the contract idempotency ledger (`claim`/`commit`/`fail`/`lookup`/`sweep`) over a declared collection of the client's store — §2.6 | `DbLedger`; structurally `@jarenjs/contract`'s `Ledger` |
|
|
117
118
|
|
|
118
119
|
`open` is the only door, and it is deliberately not a coded refusal: a
|
|
119
120
|
missing `options`, or a `validator` that is not a `JarenValidator`, is a
|
|
@@ -168,7 +169,7 @@ hands it to `explain()` and asserts `mode: 'native'` with no residual.
|
|
|
168
169
|
### 2.4 The graph
|
|
169
170
|
|
|
170
171
|
`include(pick, spec?)` opens a graph: an immutable builder of the store's
|
|
171
|
-
`load` specification, with
|
|
172
|
+
`load` specification, with 17 members of its own.
|
|
172
173
|
|
|
173
174
|
| Member | Emits | Note |
|
|
174
175
|
|---|---|---|
|
|
@@ -177,17 +178,19 @@ hands it to `explain()` and asserts `mode: 'native'` with no residual.
|
|
|
177
178
|
| `orderBy(key, options?)`, `orderByDescending(key, options?)` | `orderBy` | replaces; `options` is `{ empty?, collation? }` |
|
|
178
179
|
| `thenBy(key, options?)`, `thenByDescending(key, options?)` | appends to `orderBy` | `JL0005` when no `orderBy` precedes it |
|
|
179
180
|
| `take(n)`, `skip(n)` | `take`, `skip` | the offset window |
|
|
180
|
-
| `after(cursor)` | `after` | the keyset
|
|
181
|
+
| `after(cursor)` | `after` | the keyset continuation (§10.5) a `page()` over the same ordering emitted — typed by the declared ordering, so a bare key does not compile; the ROOT only |
|
|
181
182
|
| `maxDepth(n)` | `maxDepth` | the include depth bound (§10.4) |
|
|
182
183
|
| `asNoTracking()` | — | changes the load, never the document |
|
|
183
184
|
| `toSpec()`, `toJSON()` | the spec | plain deep-frozen JSON, a snapshot: mutating it changes nothing, and two builds are one document |
|
|
184
185
|
| `toArray()` | — | `load(spec)`: the store's one statement |
|
|
185
|
-
| `
|
|
186
|
+
| `cursor(options?)` | — | `loadCursor(spec, options)`: one root graph per pull from that same statement, its includes attached and bounded per root; `return()` releases it; `{ signal?, tracking? }` — untracked unless `tracking: true` |
|
|
187
|
+
| `page(options?)` | — | `page(spec, options)`: one bounded page over the composite keyset — `{ items, continuation, hasMore, snapshot }`, never more than `limit` roots or `maxBytes` bytes; `{ limit?, after?, maxBytes?, consistency?, signal?, tracking? }`; a `take`/`skip` on the graph beside it is the store's `JD0032` |
|
|
188
|
+
| `explain()` | — | `explainLoad(spec)`: the SQL, the includes, the pagination strategy, the per-root bounds |
|
|
186
189
|
|
|
187
190
|
The spec's member order is fixed — `where, orderBy, take, skip, after,
|
|
188
191
|
maxDepth, include` at the root; `where, orderBy, take, skip, count,
|
|
189
|
-
include` in an include — so one graph is one document
|
|
190
|
-
built. An include spec is `true` (or absent) for the rows, `{ count:
|
|
192
|
+
maxRows, maxBytes, include` in an include — so one graph is one document
|
|
193
|
+
however it was built. An include spec is `true` (or absent) for the rows, `{ count:
|
|
191
194
|
true }` for the number, or an object of clauses:
|
|
192
195
|
|
|
193
196
|
| Spec member | Emitted | Note |
|
|
@@ -198,6 +201,7 @@ true }` for the number, or an object of clauses:
|
|
|
198
201
|
| `orderBy: (p) => p.pid` | `orderBy: "$it.pid"` | a bare key, ascending |
|
|
199
202
|
| `orderBy: { key, desc?, empty?, collation? }` | `orderBy: { $key, $dir, $empty, $collation }` | as the chain spells `$orderby`; an array of either is an array |
|
|
200
203
|
| `take`, `skip` | `take`, `skip` | the window inside the subquery (a non-integer is the store's `JD0032`) |
|
|
204
|
+
| `maxRows`, `maxBytes` | `maxRows`, `maxBytes` | the per-root bounds (MODEL-FORMAT §10.4): rows of the relation per parent and serialised bytes per parent; crossing one is the store's `JD2073`, never a truncated graph. Defaults 1000 rows / 1 MiB (a `take` is the row bound of the include it windows); `Infinity` spells the unbounded case and emits as `null` |
|
|
201
205
|
| `include: { comments: spec }` | `include: { comments: <lowered> }` | over the TARGET's relation table (the scope carries every root's) |
|
|
202
206
|
| anything else | `JL0101` | the vocabulary is closed; `after` paginates the root, never an include |
|
|
203
207
|
|
|
@@ -233,6 +237,78 @@ because a collection has no relations and no tracking — and neither does
|
|
|
233
237
|
its client: a collections-only model opens a client with no
|
|
234
238
|
`saveChanges` and no `live` of its own, exactly as the store does.
|
|
235
239
|
|
|
240
|
+
### 2.6 The ledger
|
|
241
|
+
|
|
242
|
+
`createDbLedger(client, { collection = 'ledger', ttlMs = 86_400_000,
|
|
243
|
+
runtime, now })` is the `Ledger` the `@jarenjs/contract` http binding
|
|
244
|
+
calls under `policy.idempotency` (CONTRACT-FORMAT.md §8), over a
|
|
245
|
+
declared collection of the store the client opened — the collection
|
|
246
|
+
`idempotencyLedgerModel` declares, or any collection with that
|
|
247
|
+
record's schema (`collection` names it; a name the model does not
|
|
248
|
+
declare is a `TypeError` at construction, not at the first claim).
|
|
249
|
+
The implementation is the client's own surface and nothing else: it
|
|
250
|
+
imports no contract module, no driver, no store; the record it writes
|
|
251
|
+
is exactly the model's, and the id is the same versioned JSON tuple the
|
|
252
|
+
memory ledger spells (`1:["op","scope","key"]`, injective over `|`,
|
|
253
|
+
control characters and Unicode). The contract package keeps its D1
|
|
254
|
+
edge: it depends on no store, and this door depends on no contract.
|
|
255
|
+
|
|
256
|
+
**Which client decides the transaction.** A root client (the one `open`
|
|
257
|
+
answered) runs every claim, settlement, lookup and sweep in a
|
|
258
|
+
transaction of its own with `mode: 'immediate'` — the write lock taken
|
|
259
|
+
before the read, so two processes claiming one key from one file see
|
|
260
|
+
exactly one `new` and the other `in-progress`, never two handlers. The
|
|
261
|
+
client a transaction callback received runs them as savepoints inside
|
|
262
|
+
that transaction instead: a domain write and the settlement then
|
|
263
|
+
commit together or roll back together — the ledger a lifecycle
|
|
264
|
+
settlement lease carries. Atomicity is same-store only: a ledger on
|
|
265
|
+
one file and a domain write on another are two commits.
|
|
266
|
+
|
|
267
|
+
**The generation fence.** A `new` claim mints a `generation` (the
|
|
268
|
+
runtime record's `uuid`), persists it with the record and hands it
|
|
269
|
+
back in the ref (`{ id, generation }`). `commit`/`fail` settle the
|
|
270
|
+
record whose id AND generation the ref names while it is `started`; a
|
|
271
|
+
ref whose key expired, was reclaimed under a newer generation, or was
|
|
272
|
+
settled already is refused with **`JL2007`** (rejected), and the
|
|
273
|
+
record it would have touched is unchanged — across processes and
|
|
274
|
+
restarts, because the generation is in the file. The binding reports
|
|
275
|
+
the refusal to its `onError`; the response still goes out.
|
|
276
|
+
|
|
277
|
+
**Clocks and expiry.** `now` wins, then the runtime record's clock;
|
|
278
|
+
given neither, the ledger follows the instants the binding passes with
|
|
279
|
+
each call (a host-side `lookup`/`sweep` without one uses the latest).
|
|
280
|
+
A record past `expiresAt` is dropped on `claim` and `lookup`;
|
|
281
|
+
`sweep(now?)` drops every expired record and answers the count. A
|
|
282
|
+
record written under the earlier `"<op>|<scope>|<key>"` id spelling
|
|
283
|
+
is matched by no claim: it expires by its own `expiresAt`, or a host
|
|
284
|
+
rewrites its `id` once with `ledgerId` from `@jarenjs/contract/ledger`.
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
import { open, createDbLedger } from '@jarenjs/linq/db';
|
|
288
|
+
import { nodeDriver } from '@jarenjs/db/node';
|
|
289
|
+
import { idempotencyLedgerModel } from '@jarenjs/contract/ledger';
|
|
290
|
+
import { serveHttp } from '@jarenjs/contract/http';
|
|
291
|
+
|
|
292
|
+
const db = await open(idempotencyLedgerModel, { driver: nodeDriver(), path: 'ledger.db' });
|
|
293
|
+
const server = serveHttp(contract, handlers, { ledger: createDbLedger(db) }); // root: immediate claims
|
|
294
|
+
|
|
295
|
+
await db.transaction(async (tx) => { // a settlement inside the host's transaction
|
|
296
|
+
await tx.collections.orders.insert(order);
|
|
297
|
+
await createDbLedger(tx).commit(ref, response); // commits with the order, or not at all
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### 2.7 Replication documents
|
|
302
|
+
|
|
303
|
+
The DB client forwards `replication` open options to its Store. Access committed
|
|
304
|
+
pages, frontiers, conflicts and resets through `client.store.replication`.
|
|
305
|
+
`defineReplication({ replica, seq, frontier, model })` authors a logical envelope:
|
|
306
|
+
chain `.change(table, key, before, after)`, then `.toDocument()` or `.toJSON()`.
|
|
307
|
+
It shares the Store's normalization authority and preserves operation order.
|
|
308
|
+
See the [replication format](../../db/docs/REPLICATION-FORMAT.md) for causality,
|
|
309
|
+
conflict policy and bounded reset contracts. Authoring does not allocate a
|
|
310
|
+
committed sequence; normal replication exports the Store's committed pages.
|
|
311
|
+
|
|
236
312
|
## 3. Worked examples
|
|
237
313
|
|
|
238
314
|
The client's examples are not builder-to-document pairs, and this is
|
|
@@ -632,8 +708,11 @@ wrong — see [MODEL-PEN.md](MODEL-PEN.md) §5.
|
|
|
632
708
|
and only the root, because the cursor is a key of the root entity and
|
|
633
709
|
there is one root per load. `{ after: 1 }` inside a spec is `JL0101`
|
|
634
710
|
naming the graph's own `after()` (§4.1); `.after(cursor)` on the graph
|
|
635
|
-
is the spelling that works, and the graph's `after` is typed
|
|
636
|
-
|
|
711
|
+
is the spelling that works, and the graph's `after` is typed by the
|
|
712
|
+
declared ordering — the continuation a `page()` over the same
|
|
713
|
+
`orderBy`/`thenBy` chain emitted, its `keys` tuple following the
|
|
714
|
+
ordering and its `key` the row's primary key — so a bare key, or a
|
|
715
|
+
continuation with the wrong number of values, does not compile.
|
|
637
716
|
|
|
638
717
|
### 5.4 What the pin holds
|
|
639
718
|
|
|
@@ -655,7 +734,7 @@ void client.entities.Post.where((p) => p.strs.ge(3)); // a misspelled mem
|
|
|
655
734
|
void client.entities.User.include((u) => u.email); // not a relation member
|
|
656
735
|
void client.entities.User.include((u) => u.posts, { where: (p) => p.email.eq('x') }); // the target's shape
|
|
657
736
|
void client.entities.Post.include((p) => p.author, { include: { nope: true } }); // the target's relations
|
|
658
|
-
void client.entities.Post.include((p) => p.author).after('one'); // the cursor is the
|
|
737
|
+
void client.entities.Post.include((p) => p.author).after('one'); // the cursor is the ordering's continuation, never a bare key
|
|
659
738
|
client.entities.User.link('u1', 'posts', 1); // oneToMany is not a membership
|
|
660
739
|
client.entities.Post.link(1, 'author', 'u1'); // oneToOne is not a membership
|
|
661
740
|
client.entities.User.link('u1', 'labels', 42); // the target's key type
|
|
@@ -747,10 +826,10 @@ never builds one; the migration between two of them is
|
|
|
747
826
|
|
|
748
827
|
## 7. Cost
|
|
749
828
|
|
|
750
|
-
`@jarenjs/linq/db` builds to **<!--fact:bundle.db-->
|
|
829
|
+
`@jarenjs/linq/db` builds to **<!--fact:bundle.db-->623,994<!--/fact--> bytes** as a minified,
|
|
751
830
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
752
831
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
753
|
-
(<!--fact:bundle.db.kb-->
|
|
832
|
+
(<!--fact:bundle.db.kb-->624<!--/fact--> kB) beside the other nine subpath prices in
|
|
754
833
|
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
755
834
|
|
|
756
835
|
It is by far the largest of the ten, and the reason is §1.1's edge rather
|
|
@@ -777,7 +856,7 @@ What the probe asserts, and fails the build on:
|
|
|
777
856
|
asserts the same exclusion.
|
|
778
857
|
|
|
779
858
|
A consumer who wants the model pen's types without the store pays
|
|
780
|
-
`./model`'s <!--fact:bundle.model-->
|
|
859
|
+
`./model`'s <!--fact:bundle.model-->41,582<!--/fact--> bytes and installs no peer; one who wants to run
|
|
781
860
|
queries against an array rather than a database pays the chain's price
|
|
782
861
|
(§17 of [QUERY-PEN.md](QUERY-PEN.md)) and installs no peer. `./db` is
|
|
783
862
|
the one subpath whose `package.json` entry carries an optional peer at
|
|
@@ -790,7 +869,7 @@ beside Prisma, Drizzle and Kysely over the same SQLite corpus, equality
|
|
|
790
869
|
asserted before anything is timed and statement counts printed beside
|
|
791
870
|
the timings.
|
|
792
871
|
|
|
793
|
-
Against the store it fronts, the door is nearly free: <!--fact:orm.clientDoorPrice-->0
|
|
872
|
+
Against the store it fronts, the door is nearly free: <!--fact:orm.clientDoorPrice-->1.0× on a point read, 1.0× on an indexed predicate at 10 % selectivity, 1.1× on the two-level graph load<!--/fact-->
|
|
794
873
|
— because it issues the same documents the store would. What it does
|
|
795
874
|
NOT amortize is capture: a chain re-captures its callbacks and re-emits
|
|
796
875
|
its document on **every** call, by design, which is the predicate row's
|
|
@@ -798,7 +877,7 @@ difference and which a caller with a hot query removes by holding the
|
|
|
798
877
|
`Sequence` (or the emitted document) instead of rebuilding it.
|
|
799
878
|
|
|
800
879
|
Against the rivals, at this corpus, it is faster on <!--fact:orm.clientVsRivals-->8 of 9 against Prisma, 4 of 9 against Drizzle, 1 of 9 against Kysely<!--/fact-->,
|
|
801
|
-
and here is every row where the *fastest* rival beats it — <!--fact:orm.clientLosses-->update one column by primary key
|
|
880
|
+
and here is every row where the *fastest* rival beats it — <!--fact:orm.clientLosses-->update one column by primary key 13.1× (Drizzle), nested json member filter 5.8× (Kysely), cold start 3.4× (Prisma), graph load 2.8× (Kysely), posts per user 2.4× (Kysely), indexed predicate over 500 users, ids only 1.7× (Kysely), insert 1.6× (Kysely), pagination over 5000 comments, page size 20 1.6× (Kysely), point read by primary key 1.4× (Drizzle)<!--/fact-->.
|
|
802
881
|
|
|
803
882
|
Three things make that list readable rather than damning, and none of
|
|
804
883
|
them removes a row from it. **Kysely is a SQL builder**: on every row it
|
package/docs/FLOW-PEN.md
CHANGED
|
@@ -325,7 +325,7 @@ export const writing = defineDag({
|
|
|
325
325
|
nodes: {
|
|
326
326
|
brief: input(),
|
|
327
327
|
draft: task('llm', (v) => ({ prompt: v.topic })),
|
|
328
|
-
review: task('critic', (v) => ({ text: v })).checkpoint(),
|
|
328
|
+
review: task('critic', (v) => ({ text: v }), { version: '1' }).checkpoint(),
|
|
329
329
|
out: output(),
|
|
330
330
|
},
|
|
331
331
|
edges: [
|
|
@@ -342,7 +342,7 @@ export const writing = defineDag({
|
|
|
342
342
|
"nodes": {
|
|
343
343
|
"brief": { "kind": "input" },
|
|
344
344
|
"draft": { "kind": "task", "run": "llm", "with": { "prompt": "$.topic" } },
|
|
345
|
-
"review": { "kind": "task", "run": "critic", "with": { "text": "$" }, "checkpoint": true },
|
|
345
|
+
"review": { "kind": "task", "run": "critic", "version": "1", "with": { "text": "$" }, "checkpoint": true },
|
|
346
346
|
"out": { "kind": "output" }
|
|
347
347
|
},
|
|
348
348
|
"edges": [
|
|
@@ -353,6 +353,13 @@ export const writing = defineDag({
|
|
|
353
353
|
}
|
|
354
354
|
```
|
|
355
355
|
|
|
356
|
+
`review` carries a `version` because it is checkpointed: a recorded value
|
|
357
|
+
is replayed on a later run only while the handler that produced it is the
|
|
358
|
+
same one, so that identity is DECLARED — the registry must hand
|
|
359
|
+
`compileDag` the same token as `{ run, version }`, and a disagreement is
|
|
360
|
+
`JF0019` before any node runs. `.checkpoint()` refuses a task that has
|
|
361
|
+
not declared one.
|
|
362
|
+
|
|
356
363
|
`review`'s `with` is `{ "text": "$" }`: the callback returned the scope
|
|
357
364
|
value itself, and the scope value of a node with one unported inbound
|
|
358
365
|
edge IS the delivered value, verbatim (§6.1). `checkpoint: true` is
|
|
@@ -978,10 +985,10 @@ meaning is a `$return` that constructs an array explicitly.
|
|
|
978
985
|
|
|
979
986
|
## 7. Cost
|
|
980
987
|
|
|
981
|
-
`@jarenjs/linq/flow` builds to **<!--fact:bundle.flow-->19,
|
|
988
|
+
`@jarenjs/linq/flow` builds to **<!--fact:bundle.flow-->19,910<!--/fact--> bytes** as a minified,
|
|
982
989
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
983
990
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
984
|
-
(<!--fact:bundle.flow.kb-->
|
|
991
|
+
(<!--fact:bundle.flow.kb-->20<!--/fact--> kB) beside the other nine subpath prices in
|
|
985
992
|
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
986
993
|
|
|
987
994
|
The probe is a gate, not a report. Building a machine with a guard and
|
|
@@ -1008,7 +1015,7 @@ two effects as a consumer would — `defineFsm`, `state`, `on` and
|
|
|
1008
1015
|
`packages/linq/src/flow/`.
|
|
1009
1016
|
|
|
1010
1017
|
Two documents, two grammars, thirteen exported names — and 57 bytes more
|
|
1011
|
-
than `./jslt`'s <!--fact:bundle.jslt-->19,
|
|
1018
|
+
than `./jslt`'s <!--fact:bundle.jslt-->19,856<!--/fact-->, which writes one. The reason is that most of
|
|
1012
1019
|
both prices is the same shared machinery: the recording proxy
|
|
1013
1020
|
(`expression.js`), the root capture (`capture-root.js`) and the JSON
|
|
1014
1021
|
boundary (`json-boundary.js`). What this pen adds on top of them is 685
|
package/docs/FORMS-PEN.md
CHANGED
|
@@ -925,7 +925,7 @@ for this pen at all.
|
|
|
925
925
|
|
|
926
926
|
## 7. Cost
|
|
927
927
|
|
|
928
|
-
`@jarenjs/linq/forms` builds to **<!--fact:bundle.forms-->
|
|
928
|
+
`@jarenjs/linq/forms` builds to **<!--fact:bundle.forms-->37,312<!--/fact--> bytes** as a minified,
|
|
929
929
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
930
930
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
931
931
|
beside the other nine subpath prices in
|
|
@@ -934,7 +934,7 @@ pen it subclasses, and no chain module, no `@jarenjs/forms` byte and no
|
|
|
934
934
|
model pen.
|
|
935
935
|
|
|
936
936
|
Most of that figure is the schema pen: `@jarenjs/linq/schema` alone
|
|
937
|
-
is <!--fact:bundle.schema-->
|
|
937
|
+
is <!--fact:bundle.schema-->33,156<!--/fact--> bytes, so the whole `x-form` vocabulary — the mixin, the rule
|
|
938
938
|
capture, the submit transform and their refusal messages — is about 4 kB
|
|
939
939
|
on top of a pen a form-shaped consumer usually already carries. A
|
|
940
940
|
consumer importing both subpaths pays the schema pen once.
|
package/docs/JSLT-PEN.md
CHANGED
|
@@ -919,10 +919,10 @@ non-judgement is itself gated.
|
|
|
919
919
|
|
|
920
920
|
## 7. Cost
|
|
921
921
|
|
|
922
|
-
`@jarenjs/linq/jslt` builds to **<!--fact:bundle.jslt-->19,
|
|
922
|
+
`@jarenjs/linq/jslt` builds to **<!--fact:bundle.jslt-->19,856<!--/fact--> bytes** as a minified,
|
|
923
923
|
tree-shaken ESM bundle — the figure `scripts/check-tree-shaking.js`
|
|
924
924
|
measures and `npm run test:tree-shaking` reports, published rounded
|
|
925
|
-
(<!--fact:bundle.jslt.kb-->
|
|
925
|
+
(<!--fact:bundle.jslt.kb-->20<!--/fact--> kB) beside the other nine subpath prices in
|
|
926
926
|
[docs/CONSUMING.md](../../../docs/CONSUMING.md).
|
|
927
927
|
|
|
928
928
|
The probe is a gate, not a report. Building a stylesheet as a consumer
|
|
@@ -948,8 +948,8 @@ source. That makes it the SMALLEST of the nine pen bundles, and the
|
|
|
948
948
|
reason is that a stylesheet is mostly bodies, and a body is the shared
|
|
949
949
|
machine every pen already pays for.
|
|
950
950
|
|
|
951
|
-
Two figures worth reading beside it: `./migration` (<!--fact:bundle.migration-->
|
|
951
|
+
Two figures worth reading beside it: `./migration` (<!--fact:bundle.migration-->24,259<!--/fact--> bytes)
|
|
952
952
|
carries this pen's `body()` and pays for it, which is why the two prices
|
|
953
|
-
sit so close; and `./flow` (<!--fact:bundle.flow-->19,
|
|
953
|
+
sit so close; and `./flow` (<!--fact:bundle.flow-->19,910<!--/fact--> bytes) is within 60 bytes of this one
|
|
954
954
|
despite writing two formats, because it shares the same capture and adds
|
|
955
955
|
almost nothing but member checks and their messages.
|