@lazily-hub/lazily-js 0.15.0 → 0.18.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/README.md +41 -2
- package/package.json +16 -2
- package/src/index.d.ts +25 -5
- package/src/index.js +68 -11
- package/src/indexeddb-outbox.d.ts +18 -0
- package/src/indexeddb-outbox.js +124 -0
- package/src/merge.d.ts +50 -0
- package/src/merge.js +142 -0
- package/src/queue.d.ts +97 -1
- package/src/queue.js +393 -16
- package/src/relay.d.ts +184 -0
- package/src/relay.js +622 -0
- package/src/text-crdt.d.ts +21 -1
- package/src/text-crdt.js +10 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ the FFI projection transport is used.
|
|
|
33
33
|
The full `lazily` capability set across every binding. Legend: ✅ shipped ·
|
|
34
34
|
`~` partial · `—` absent or not applicable. The canonical matrix with per-cell
|
|
35
35
|
notes and platform carve-outs lives in
|
|
36
|
-
[`lazily-spec` § Cross-Language Coverage](
|
|
36
|
+
[`lazily-spec` § Cross-Language Coverage](https://github.com/lazily-hub/lazily-spec/blob/main/docs/coverage.md).
|
|
37
37
|
|
|
38
38
|
<!-- coverage-table:start -->
|
|
39
39
|
| Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
|
|
@@ -51,8 +51,13 @@ notes and platform carve-outs lives in
|
|
|
51
51
|
| Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
52
52
|
| Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
53
53
|
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
|
+
| Broadcast topic (`TopicCell`) — independent cursors + durable replay + safe GC (`#lztopiccell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
+
| Competing-consumer work queue (`WorkQueueCell`) — exclusive leases + ack/nack + redelivery + DLQ (`#lzworkqueue`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
|
+
| Merge algebra + `MergeCell` — associative `MergePolicy` (`KeepLatest`/`Sum`/`Max`/`SetUnion`/`RawFifo`), `Cell ≡ MergeCell<KeepLatest>`, `Reactive`/`Source` split (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
|
+
| RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
58
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
59
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
|
+
| `CrdtTree` lossless document contract (`#lzcrdttree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
61
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
62
|
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
63
|
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -63,6 +68,7 @@ notes and platform carve-outs lives in
|
|
|
63
68
|
| Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
64
69
|
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
65
70
|
| Reliable sync — resync coordinator + at-least-once durable outbox + OR-set/LWW liveness (`#lzsync`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
71
|
+
| Storage-independent durable outbox (`OutboxStore` + shared outbox protocol; SQLite/Room/IndexedDB/file adapters) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
72
|
| Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
73
|
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
74
|
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -335,6 +341,26 @@ tree.value(); // 1
|
|
|
335
341
|
tree.setValue("leaf", 99); // only the ancestor chain recomputes
|
|
336
342
|
```
|
|
337
343
|
|
|
344
|
+
## Competing-consumer work queue
|
|
345
|
+
|
|
346
|
+
The `queue` package exports `WorkQueueCell`, a pull-based local authority with
|
|
347
|
+
exclusive FIFO claims, stable item IDs, fresh delivery IDs per attempt,
|
|
348
|
+
worker-owned ack/nack, strict visibility-timeout redelivery, bounded attempts,
|
|
349
|
+
and DLQ routing. Every mutation reports exact `pending_len` / `is_empty` /
|
|
350
|
+
`in_flight_len` / `dead_letter_len` invalidation metadata.
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
import { WorkQueueCell } from "@lazily-hub/lazily-js/queue";
|
|
354
|
+
|
|
355
|
+
const work = new WorkQueueCell({ visibility_timeout: 30, max_deliveries: 3 });
|
|
356
|
+
work.push("render-report");
|
|
357
|
+
const delivery = work.claim("worker-a", 100).returns;
|
|
358
|
+
work.ack("worker-a", delivery.delivery_id);
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The instance serializes local claims; distributed/HA assignment still requires
|
|
362
|
+
a leader or consensus-committed assignment log.
|
|
363
|
+
|
|
338
364
|
## CRDTs
|
|
339
365
|
|
|
340
366
|
`SeqCrdt` is the move-aware sequence CRDT: each element has independent LWW
|
|
@@ -342,7 +368,10 @@ registers for value, position, and deletion, so a move is one position
|
|
|
342
368
|
assignment rather than delete plus reinsert. `TextCrdt` is a Fugue/RGA
|
|
343
369
|
character CRDT: concurrent same-point inserts are preserved, deletes are sticky
|
|
344
370
|
tombstones, and merge is commutative / associative / idempotent. Both expose
|
|
345
|
-
tombstone GC behind caller-supplied causal-stability watermarks.
|
|
371
|
+
tombstone GC behind caller-supplied causal-stability watermarks. `TextCrdt`
|
|
372
|
+
also satisfies the `CrdtTree` document contract: its snapshot is the delta from
|
|
373
|
+
an empty frontier, so full hydration and incremental exchange preserve the same
|
|
374
|
+
identity-bearing state.
|
|
346
375
|
|
|
347
376
|
```js
|
|
348
377
|
import { SeqCrdt } from "@lazily-hub/lazily-js/seq-crdt";
|
|
@@ -358,6 +387,16 @@ peer.insert(2, "!");
|
|
|
358
387
|
text.merge(peer); // converges
|
|
359
388
|
```
|
|
360
389
|
|
|
390
|
+
## Durable outbox stores
|
|
391
|
+
|
|
392
|
+
The root `Outbox` class owns one append/ack/prune/replay protocol over the
|
|
393
|
+
five-operation `OutboxStore` boundary. `InMemoryStore` exercises that path in
|
|
394
|
+
tests. Browsers can open an `IndexedDbStore` from
|
|
395
|
+
`@lazily-hub/lazily-js/indexeddb-outbox`; await `append` before transport send
|
|
396
|
+
and `ackThrough` before treating an acknowledgement as committed. Reopening the
|
|
397
|
+
same database and channel restores the durable cursor and only unacknowledged
|
|
398
|
+
frames.
|
|
399
|
+
|
|
361
400
|
## IPC wire types and capability negotiation
|
|
362
401
|
|
|
363
402
|
Every IPC value round-trips the canonical externally-tagged
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), sync/async/thread-safe keyed reactive maps (ReactiveMap/CellMap/SlotMap), the lazily-spec IPC wire types + C-ABI FFI boundary (isomorphic channel + Node native binding), keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, an FFI state-projection consumer, and an in-library instrumentation/benchmark API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -69,6 +69,14 @@
|
|
|
69
69
|
"types": "./src/queue.d.ts",
|
|
70
70
|
"default": "./src/queue.js"
|
|
71
71
|
},
|
|
72
|
+
"./merge": {
|
|
73
|
+
"types": "./src/merge.d.ts",
|
|
74
|
+
"default": "./src/merge.js"
|
|
75
|
+
},
|
|
76
|
+
"./relay": {
|
|
77
|
+
"types": "./src/relay.d.ts",
|
|
78
|
+
"default": "./src/relay.js"
|
|
79
|
+
},
|
|
72
80
|
"./sem-tree": {
|
|
73
81
|
"types": "./src/sem-tree.d.ts",
|
|
74
82
|
"default": "./src/sem-tree.js"
|
|
@@ -89,6 +97,11 @@
|
|
|
89
97
|
"types": "./src/lossless-tree-crdt.d.ts",
|
|
90
98
|
"default": "./src/lossless-tree-crdt.js"
|
|
91
99
|
},
|
|
100
|
+
"./indexeddb-outbox": {
|
|
101
|
+
"types": "./src/indexeddb-outbox.d.ts",
|
|
102
|
+
"browser": "./src/indexeddb-outbox.js",
|
|
103
|
+
"default": "./src/indexeddb-outbox.js"
|
|
104
|
+
},
|
|
92
105
|
"./state-projection": {
|
|
93
106
|
"types": "./src/state-projection.d.ts",
|
|
94
107
|
"default": "./src/state-projection.js"
|
|
@@ -103,7 +116,7 @@
|
|
|
103
116
|
}
|
|
104
117
|
},
|
|
105
118
|
"scripts": {
|
|
106
|
-
"build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
119
|
+
"build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/async-reactive-family.js && node --check src/thread-safe.js && node --check src/thread-safe-reactive-family.js && node --check src/ffi.js && node --check src/ffi-node.js && node --check src/instrumentation.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/merge.js && node --check src/relay.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/indexeddb-outbox.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/graph-view.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
107
120
|
"test:formal": "node scripts/formal-check.mjs",
|
|
108
121
|
"bench": "node bench/context.bench.mjs",
|
|
109
122
|
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
@@ -117,6 +130,7 @@
|
|
|
117
130
|
"devDependencies": {
|
|
118
131
|
"@types/node": "^22.0.0",
|
|
119
132
|
"ajv": "^8.20.0",
|
|
133
|
+
"fake-indexeddb": "^6.2.5",
|
|
120
134
|
"tsx": "^4.19.0",
|
|
121
135
|
"typescript": "^5.6.0"
|
|
122
136
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -449,20 +449,40 @@ export class ResyncCoordinator {
|
|
|
449
449
|
}
|
|
450
450
|
|
|
451
451
|
export interface DurableOutbox {
|
|
452
|
-
append(epoch: number, msg: IpcMessage): void
|
|
453
|
-
ackThrough(epoch: number): void
|
|
452
|
+
append(epoch: number, msg: IpcMessage): void | Promise<void>;
|
|
453
|
+
ackThrough(epoch: number): void | Promise<void>;
|
|
454
454
|
replayFrom(cursor: number): Array<[number, IpcMessage]>;
|
|
455
455
|
retainedEpochs(): number[];
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
-
export
|
|
458
|
+
export interface OutboxStore {
|
|
459
|
+
put(epoch: number, frame: Uint8Array): void | Promise<void>;
|
|
460
|
+
deleteThrough(epoch: number): void | Promise<void>;
|
|
461
|
+
scanAfter(cursor: number): Array<[number, Uint8Array]>;
|
|
462
|
+
loadCursor(): number;
|
|
463
|
+
saveCursor(epoch: number): void | Promise<void>;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export class Outbox<S extends OutboxStore = OutboxStore> implements DurableOutbox {
|
|
467
|
+
constructor(store: S);
|
|
468
|
+
readonly store: S;
|
|
459
469
|
ackedThrough: number;
|
|
460
|
-
append(epoch: number, msg: IpcMessage): void
|
|
461
|
-
ackThrough(epoch: number): void
|
|
470
|
+
append(epoch: number, msg: IpcMessage): void | Promise<void>;
|
|
471
|
+
ackThrough(epoch: number): void | Promise<void>;
|
|
462
472
|
replayFrom(cursor: number): Array<[number, IpcMessage]>;
|
|
463
473
|
retainedEpochs(): number[];
|
|
464
474
|
}
|
|
465
475
|
|
|
476
|
+
export class InMemoryStore implements OutboxStore {
|
|
477
|
+
put(epoch: number, frame: Uint8Array): void;
|
|
478
|
+
deleteThrough(epoch: number): void;
|
|
479
|
+
scanAfter(cursor: number): Array<[number, Uint8Array]>;
|
|
480
|
+
loadCursor(): number;
|
|
481
|
+
saveCursor(epoch: number): void;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export class InMemoryOutbox extends Outbox<InMemoryStore> {}
|
|
485
|
+
|
|
466
486
|
export class OrSet {
|
|
467
487
|
add(tag: string): void;
|
|
468
488
|
removeObserved(tags: Iterable<string>): void;
|
package/src/index.js
CHANGED
|
@@ -1269,29 +1269,86 @@ export class ResyncCoordinator {
|
|
|
1269
1269
|
}
|
|
1270
1270
|
}
|
|
1271
1271
|
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
this.
|
|
1272
|
+
// A byte-oriented OutboxStore is any object with
|
|
1273
|
+
// put/deleteThrough/scanAfter/loadCursor/saveCursor. Stores are deliberately
|
|
1274
|
+
// protocol-dumb; Outbox is the one append/ack/replay implementation.
|
|
1275
|
+
export class Outbox {
|
|
1276
|
+
constructor(store) {
|
|
1277
|
+
this.store = store;
|
|
1278
|
+
this.ackedThrough = store.loadCursor();
|
|
1278
1279
|
}
|
|
1279
1280
|
|
|
1280
1281
|
append(epoch, msg) {
|
|
1281
|
-
this.
|
|
1282
|
+
return this.store.put(epoch, msg.encodeJson());
|
|
1282
1283
|
}
|
|
1283
1284
|
|
|
1284
1285
|
ackThrough(epoch) {
|
|
1285
|
-
if (epoch
|
|
1286
|
-
|
|
1286
|
+
if (epoch <= this.ackedThrough) return undefined;
|
|
1287
|
+
const target = () => Math.max(this.ackedThrough, epoch, this.store.loadCursor());
|
|
1288
|
+
const finish = () => {
|
|
1289
|
+
this.ackedThrough = target();
|
|
1290
|
+
};
|
|
1291
|
+
const remove = () => this.store.deleteThrough(target());
|
|
1292
|
+
const saved = this.store.saveCursor(epoch);
|
|
1293
|
+
if (saved && typeof saved.then === "function") {
|
|
1294
|
+
return saved.then(remove).then(finish);
|
|
1295
|
+
}
|
|
1296
|
+
const removed = remove();
|
|
1297
|
+
if (removed && typeof removed.then === "function") {
|
|
1298
|
+
return removed.then(finish);
|
|
1299
|
+
}
|
|
1300
|
+
finish();
|
|
1301
|
+
return undefined;
|
|
1287
1302
|
}
|
|
1288
1303
|
|
|
1289
1304
|
replayFrom(cursor) {
|
|
1290
|
-
return this.
|
|
1305
|
+
return this.store
|
|
1306
|
+
.scanAfter(Math.max(cursor, this.ackedThrough))
|
|
1307
|
+
.sort((a, b) => a[0] - b[0])
|
|
1308
|
+
.map(([epoch, frame]) => [epoch, IpcMessage.decodeJson(frame)]);
|
|
1291
1309
|
}
|
|
1292
1310
|
|
|
1293
1311
|
retainedEpochs() {
|
|
1294
|
-
return this.
|
|
1312
|
+
return this.store.scanAfter(this.ackedThrough).map(([epoch]) => epoch).sort((a, b) => a - b);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export class InMemoryStore {
|
|
1317
|
+
constructor() {
|
|
1318
|
+
this.entries = new Map();
|
|
1319
|
+
this.cursor = 0;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
put(epoch, frame) {
|
|
1323
|
+
this.entries.set(epoch, Uint8Array.from(frame));
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
deleteThrough(epoch) {
|
|
1327
|
+
for (const key of this.entries.keys()) {
|
|
1328
|
+
if (key <= epoch) this.entries.delete(key);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
scanAfter(epoch) {
|
|
1333
|
+
return [...this.entries]
|
|
1334
|
+
.filter(([key]) => key > epoch)
|
|
1335
|
+
.sort((a, b) => a[0] - b[0])
|
|
1336
|
+
.map(([key, frame]) => [key, Uint8Array.from(frame)]);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
loadCursor() {
|
|
1340
|
+
return this.cursor;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
saveCursor(epoch) {
|
|
1344
|
+
this.cursor = Math.max(this.cursor, epoch);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// Process-local default, routed through the same protocol as persistent stores.
|
|
1349
|
+
export class InMemoryOutbox extends Outbox {
|
|
1350
|
+
constructor() {
|
|
1351
|
+
super(new InMemoryStore());
|
|
1295
1352
|
}
|
|
1296
1353
|
}
|
|
1297
1354
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OutboxStore } from "./index.js";
|
|
2
|
+
|
|
3
|
+
export interface IndexedDbStoreOptions {
|
|
4
|
+
channel: string;
|
|
5
|
+
database?: string;
|
|
6
|
+
version?: number;
|
|
7
|
+
indexedDB?: IDBFactory;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class IndexedDbStore implements OutboxStore {
|
|
11
|
+
static open(options: IndexedDbStoreOptions): Promise<IndexedDbStore>;
|
|
12
|
+
put(epoch: number, frame: Uint8Array): Promise<void>;
|
|
13
|
+
deleteThrough(epoch: number): Promise<void>;
|
|
14
|
+
scanAfter(cursor: number): Array<[number, Uint8Array]>;
|
|
15
|
+
loadCursor(): number;
|
|
16
|
+
saveCursor(epoch: number): Promise<void>;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser IndexedDB storage adapter for the root `Outbox` protocol.
|
|
3
|
+
*
|
|
4
|
+
* `open()` hydrates a synchronous replay mirror. Mutations return promises;
|
|
5
|
+
* callers must await `Outbox.append` before sending and `Outbox.ackThrough`
|
|
6
|
+
* before treating an ack as committed.
|
|
7
|
+
*/
|
|
8
|
+
export class IndexedDbStore {
|
|
9
|
+
#db;
|
|
10
|
+
#channel;
|
|
11
|
+
#entries;
|
|
12
|
+
#cursor;
|
|
13
|
+
#cursorWrites = Promise.resolve();
|
|
14
|
+
|
|
15
|
+
constructor(db, channel, entries, cursor) {
|
|
16
|
+
this.#db = db;
|
|
17
|
+
this.#channel = channel;
|
|
18
|
+
this.#entries = entries;
|
|
19
|
+
this.#cursor = cursor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static async open({
|
|
23
|
+
channel,
|
|
24
|
+
database = "lazily-reliable-sync",
|
|
25
|
+
version = 1,
|
|
26
|
+
indexedDB = globalThis.indexedDB,
|
|
27
|
+
}) {
|
|
28
|
+
if (!indexedDB) throw new Error("IndexedDB is unavailable in this environment");
|
|
29
|
+
const db = await new Promise((resolve, reject) => {
|
|
30
|
+
const request = indexedDB.open(database, version);
|
|
31
|
+
request.onupgradeneeded = () => {
|
|
32
|
+
const next = request.result;
|
|
33
|
+
if (!next.objectStoreNames.contains("frames")) {
|
|
34
|
+
next.createObjectStore("frames", { keyPath: ["channel", "epoch"] });
|
|
35
|
+
}
|
|
36
|
+
if (!next.objectStoreNames.contains("cursors")) {
|
|
37
|
+
next.createObjectStore("cursors", { keyPath: "channel" });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
request.onsuccess = () => resolve(request.result);
|
|
41
|
+
request.onerror = () => reject(request.error);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const entries = new Map();
|
|
45
|
+
const cursor = await new Promise((resolve, reject) => {
|
|
46
|
+
const tx = db.transaction(["frames", "cursors"], "readonly");
|
|
47
|
+
const frameRequest = tx.objectStore("frames").openCursor();
|
|
48
|
+
frameRequest.onsuccess = () => {
|
|
49
|
+
const row = frameRequest.result;
|
|
50
|
+
if (!row) return;
|
|
51
|
+
if (row.value.channel === channel) {
|
|
52
|
+
entries.set(row.value.epoch, Uint8Array.from(row.value.frame));
|
|
53
|
+
}
|
|
54
|
+
row.continue();
|
|
55
|
+
};
|
|
56
|
+
const cursorRequest = tx.objectStore("cursors").get(channel);
|
|
57
|
+
tx.oncomplete = () => resolve(cursorRequest.result?.epoch ?? 0);
|
|
58
|
+
tx.onerror = () => reject(tx.error);
|
|
59
|
+
tx.onabort = () => reject(tx.error);
|
|
60
|
+
});
|
|
61
|
+
return new IndexedDbStore(db, channel, entries, cursor);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#transaction(storeName, mode, operation) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const tx = this.#db.transaction(storeName, mode);
|
|
67
|
+
operation(tx.objectStore(storeName));
|
|
68
|
+
tx.oncomplete = () => resolve();
|
|
69
|
+
tx.onerror = () => reject(tx.error);
|
|
70
|
+
tx.onabort = () => reject(tx.error);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async put(epoch, frame) {
|
|
75
|
+
const bytes = Uint8Array.from(frame);
|
|
76
|
+
await this.#transaction("frames", "readwrite", (store) => {
|
|
77
|
+
store.put({ channel: this.#channel, epoch, frame: bytes });
|
|
78
|
+
});
|
|
79
|
+
this.#entries.set(epoch, bytes);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async deleteThrough(epoch) {
|
|
83
|
+
const keys = [...this.#entries.keys()].filter((key) => key <= epoch);
|
|
84
|
+
await this.#transaction("frames", "readwrite", (store) => {
|
|
85
|
+
for (const key of keys) store.delete([this.#channel, key]);
|
|
86
|
+
});
|
|
87
|
+
for (const key of keys) this.#entries.delete(key);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
scanAfter(epoch) {
|
|
91
|
+
return [...this.#entries]
|
|
92
|
+
.filter(([key]) => key > epoch)
|
|
93
|
+
.sort((a, b) => a[0] - b[0])
|
|
94
|
+
.map(([key, frame]) => [key, Uint8Array.from(frame)]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
loadCursor() {
|
|
98
|
+
return this.#cursor;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
saveCursor(epoch) {
|
|
102
|
+
const previous = this.#cursorWrites.catch(() => undefined);
|
|
103
|
+
const write = previous.then(async () => {
|
|
104
|
+
let next = Math.max(this.#cursor, epoch);
|
|
105
|
+
await this.#transaction("cursors", "readwrite", (store) => {
|
|
106
|
+
// IndexedDB serializes readwrite transactions that overlap this store.
|
|
107
|
+
// Reading and writing inside one transaction makes this a database-wide
|
|
108
|
+
// max, not merely a per-JavaScript-handle max.
|
|
109
|
+
const request = store.get(this.#channel);
|
|
110
|
+
request.onsuccess = () => {
|
|
111
|
+
next = Math.max(request.result?.epoch ?? 0, this.#cursor, epoch);
|
|
112
|
+
store.put({ channel: this.#channel, epoch: next });
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
this.#cursor = next;
|
|
116
|
+
});
|
|
117
|
+
this.#cursorWrites = write;
|
|
118
|
+
return write;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
close() {
|
|
122
|
+
this.#db.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/merge.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Phase 1 of the RelayCell backpressure plan (#relaycell) — merge algebra +
|
|
2
|
+
// the Reactive/Source read/write split. See lazily-spec/docs/reactive-graph.md
|
|
3
|
+
// § "MergeCell and the merge algebra".
|
|
4
|
+
|
|
5
|
+
import type { CellHandle, Context } from "./reactive.js";
|
|
6
|
+
|
|
7
|
+
/** An associative merge policy `⊕: T×T→T` with its transport-selected flags. */
|
|
8
|
+
export interface MergePolicy<T> {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
/** The associative fold. MUST satisfy `(a⊕b)⊕c == a⊕(b⊕c)`. */
|
|
11
|
+
merge(old: T, op: T): T;
|
|
12
|
+
/** `⊕` is commutative (reordering tax). */
|
|
13
|
+
readonly commutative: boolean;
|
|
14
|
+
/** `⊕` is idempotent — `(a⊕b)⊕b == a⊕b` (durability tax). */
|
|
15
|
+
readonly idempotent: boolean;
|
|
16
|
+
/** Conflation bounds the state (the `Conflate` overflow precondition). */
|
|
17
|
+
readonly conflates: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const KeepLatest: MergePolicy<unknown>;
|
|
21
|
+
export const Sum: MergePolicy<number>;
|
|
22
|
+
export const Max: MergePolicy<number>;
|
|
23
|
+
export const SetUnion: MergePolicy<Set<unknown>>;
|
|
24
|
+
export const RawFifo: MergePolicy<unknown[]>;
|
|
25
|
+
|
|
26
|
+
/** A cell whose write is a merge under `policy` (`Cell ≡ MergeCell(KeepLatest)`). */
|
|
27
|
+
export class MergeCell<T> {
|
|
28
|
+
readonly ctx: Context;
|
|
29
|
+
readonly cell: CellHandle<T>;
|
|
30
|
+
readonly policy: MergePolicy<T>;
|
|
31
|
+
constructor(ctx: Context, cell: CellHandle<T>, policy: MergePolicy<T>);
|
|
32
|
+
/** Read the current converged value. */
|
|
33
|
+
get(): T;
|
|
34
|
+
/** Replace the value (keep-latest write). */
|
|
35
|
+
set(value: T): void;
|
|
36
|
+
/** Fold `op` into the value under the policy. */
|
|
37
|
+
merge(op: T): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Create a `MergeCell` over `ctx`. */
|
|
41
|
+
export function mergeCell<T>(ctx: Context, initial: T, policy: MergePolicy<T>): MergeCell<T>;
|
|
42
|
+
|
|
43
|
+
/** Adapt a plain cell to the `Source` shape (get/set/merge; merge == replace). */
|
|
44
|
+
export function asSource<T>(
|
|
45
|
+
ctx: Context,
|
|
46
|
+
cellHandle: CellHandle<T>,
|
|
47
|
+
): { get(): T; set(value: T): void; merge(op: T): void };
|
|
48
|
+
|
|
49
|
+
/** Adapt any read handle to the `Reactive` shape (`{ get }`). */
|
|
50
|
+
export function asReactive<T>(ctx: Context, handle: unknown): { get(): T };
|
package/src/merge.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Phase 1 of the RelayCell backpressure plan (#relaycell) — the merge algebra
|
|
2
|
+
// and the Reactive/Source read/write split.
|
|
3
|
+
//
|
|
4
|
+
// See lazily-spec/docs/reactive-graph.md § "MergeCell and the merge algebra" and
|
|
5
|
+
// relaycell-backpressure-analysis.md §4.0/§4.3. A merge policy is an *associative*
|
|
6
|
+
// fold ⊕: T×T→T; the properties it satisfies (associativity always; commutativity
|
|
7
|
+
// = reordering tax; idempotency = durability tax) select which overflow behaviour
|
|
8
|
+
// is sound. `MergeCell` generalizes a plain `Cell` — `Cell ≡ MergeCell(KeepLatest)`
|
|
9
|
+
// — a source whose write is a merge. Backed by an ordinary cell, so it inherits
|
|
10
|
+
// the Phase-0 `==` store-guard and store-without-cascade.
|
|
11
|
+
|
|
12
|
+
// -- Merge policies ----------------------------------------------------------
|
|
13
|
+
//
|
|
14
|
+
// A policy is a plain object: { name, merge(old, op), commutative, idempotent,
|
|
15
|
+
// conflates }. Associativity is a law (verified by law-tests), not a field. The
|
|
16
|
+
// three flags surface the transport-selected branches; `conflates` gates the
|
|
17
|
+
// `Conflate` overflow (only RawFifo — concat — cannot bound, Phase 2).
|
|
18
|
+
|
|
19
|
+
/** Keep-latest band: `old ⊕ op = op`. The policy behind a plain `Cell`. */
|
|
20
|
+
export const KeepLatest = Object.freeze({
|
|
21
|
+
name: "KeepLatest",
|
|
22
|
+
merge: (_old, op) => op,
|
|
23
|
+
commutative: false,
|
|
24
|
+
idempotent: true,
|
|
25
|
+
conflates: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Additive commutative monoid: `old + op`. Not idempotent. */
|
|
29
|
+
export const Sum = Object.freeze({
|
|
30
|
+
name: "Sum",
|
|
31
|
+
merge: (old, op) => old + op,
|
|
32
|
+
commutative: true,
|
|
33
|
+
idempotent: false,
|
|
34
|
+
conflates: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Max semilattice: `max(old, op)`. Associative, commutative, idempotent. */
|
|
38
|
+
export const Max = Object.freeze({
|
|
39
|
+
name: "Max",
|
|
40
|
+
merge: (old, op) => (op > old ? op : old),
|
|
41
|
+
commutative: true,
|
|
42
|
+
idempotent: true,
|
|
43
|
+
conflates: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Grow-only set-union semilattice over `Set`. */
|
|
47
|
+
export const SetUnion = Object.freeze({
|
|
48
|
+
name: "SetUnion",
|
|
49
|
+
merge: (old, op) => {
|
|
50
|
+
const out = new Set(old);
|
|
51
|
+
for (const x of op) out.add(x);
|
|
52
|
+
return out;
|
|
53
|
+
},
|
|
54
|
+
commutative: true,
|
|
55
|
+
idempotent: true,
|
|
56
|
+
conflates: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Raw FIFO append over arrays: `old ++ op`. Order + multiplicity are meaning —
|
|
60
|
+
* associative only; cannot conflate. */
|
|
61
|
+
export const RawFifo = Object.freeze({
|
|
62
|
+
name: "RawFifo",
|
|
63
|
+
merge: (old, op) => old.concat(op),
|
|
64
|
+
commutative: false,
|
|
65
|
+
idempotent: false,
|
|
66
|
+
conflates: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// -- MergeCell ---------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A cell whose write is a *merge* under `policy`, rather than a replace.
|
|
73
|
+
* `Cell ≡ MergeCell(KeepLatest)`.
|
|
74
|
+
*/
|
|
75
|
+
export class MergeCell {
|
|
76
|
+
/** @param {import("./reactive.js").Context} ctx */
|
|
77
|
+
constructor(ctx, cell, policy) {
|
|
78
|
+
this.ctx = ctx;
|
|
79
|
+
/** underlying CellHandle */
|
|
80
|
+
this.cell = cell;
|
|
81
|
+
this.policy = policy;
|
|
82
|
+
Object.freeze(this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read the current converged value (tracks a dependency inside a computation). */
|
|
86
|
+
get() {
|
|
87
|
+
return this.ctx.getCell(this.cell);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Replace the value outright (the keep-latest write), bypassing the policy. */
|
|
91
|
+
set(value) {
|
|
92
|
+
this.ctx.setCell(this.cell, value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Fold `op` into the current value under the policy. Routes through `setCell`
|
|
96
|
+
* so the `==` store-guard (free dedup for idempotent ⊕) + store-without-cascade
|
|
97
|
+
* apply unchanged. */
|
|
98
|
+
merge(op) {
|
|
99
|
+
const old = this.ctx.getCell(this.cell);
|
|
100
|
+
this.ctx.setCell(this.cell, this.policy.merge(old, op));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Create a `MergeCell` over `ctx` with `initial` value under `policy`. */
|
|
105
|
+
export function mergeCell(ctx, initial, policy) {
|
|
106
|
+
return new MergeCell(ctx, ctx.cell(initial), policy);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -- Reactive / Source -------------------------------------------------------
|
|
110
|
+
//
|
|
111
|
+
// JavaScript is structurally typed, so the `Reactive<T>` read supertype and the
|
|
112
|
+
// `Source<T>: Reactive<T>` write sub-interface are documented shapes rather than
|
|
113
|
+
// declared types. A reader (Slot/Signal) exposes { get }; a source (Cell/MergeCell)
|
|
114
|
+
// exposes { get, set, merge }. `asSource` adapts a plain CellHandle to the Source
|
|
115
|
+
// shape (its `merge` is a replace — Cell ≡ MergeCell(KeepLatest)).
|
|
116
|
+
|
|
117
|
+
/** Adapt a plain `CellHandle` to the `Source` shape (get/set/merge). */
|
|
118
|
+
export function asSource(ctx, cellHandle) {
|
|
119
|
+
return {
|
|
120
|
+
get: () => ctx.getCell(cellHandle),
|
|
121
|
+
set: (value) => ctx.setCell(cellHandle, value),
|
|
122
|
+
// Cell ≡ MergeCell(KeepLatest): merge replaces.
|
|
123
|
+
merge: (op) => ctx.setCell(cellHandle, op),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Adapt any read handle (Slot/Signal/Cell) to the `Reactive` shape ({ get }). */
|
|
128
|
+
export function asReactive(ctx, handle) {
|
|
129
|
+
// Cells read via getCell; slots/signals via get/getSignal. Detect by shape.
|
|
130
|
+
if (handle instanceof MergeCell) return { get: () => handle.get() };
|
|
131
|
+
if (handle && handle.slot !== undefined) return { get: () => ctx.getSignal(handle) };
|
|
132
|
+
// Fall back: try getCell then get.
|
|
133
|
+
return {
|
|
134
|
+
get: () => {
|
|
135
|
+
try {
|
|
136
|
+
return ctx.getCell(handle);
|
|
137
|
+
} catch {
|
|
138
|
+
return ctx.get(handle);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|