@lazily-hub/lazily-js 0.16.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 +40 -3
- package/package.json +8 -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/queue.d.ts +97 -1
- package/src/queue.js +393 -16
- 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,10 +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`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
56
|
| Merge algebra + `MergeCell` — associative `MergePolicy` (`KeepLatest`/`Sum`/`Max`/`SetUnion`/`RawFifo`), `Cell ≡ MergeCell<KeepLatest>`, `Reactive`/`Source` split (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
-
| RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ |
|
|
57
|
+
| RelayCell — conflating relay + `BackpressurePolicy` + `SpillStore` + `Transport` + Inbox/Outbox + Rate/Window/Expiry/Priority/keyed policies (`#relaycell`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
58
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
59
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
|
+
| `CrdtTree` lossless document contract (`#lzcrdttree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
61
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
59
62
|
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
63
|
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -65,6 +68,7 @@ notes and platform carve-outs lives in
|
|
|
65
68
|
| Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
69
|
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
67
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) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
72
|
| Reliable-sync transport seam + full-duplex `SyncDriver` loop (`IpcSink`/`IpcSource`, `#sync-driver`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
69
73
|
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
70
74
|
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -337,6 +341,26 @@ tree.value(); // 1
|
|
|
337
341
|
tree.setValue("leaf", 99); // only the ancestor chain recomputes
|
|
338
342
|
```
|
|
339
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
|
+
|
|
340
364
|
## CRDTs
|
|
341
365
|
|
|
342
366
|
`SeqCrdt` is the move-aware sequence CRDT: each element has independent LWW
|
|
@@ -344,7 +368,10 @@ registers for value, position, and deletion, so a move is one position
|
|
|
344
368
|
assignment rather than delete plus reinsert. `TextCrdt` is a Fugue/RGA
|
|
345
369
|
character CRDT: concurrent same-point inserts are preserved, deletes are sticky
|
|
346
370
|
tombstones, and merge is commutative / associative / idempotent. Both expose
|
|
347
|
-
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.
|
|
348
375
|
|
|
349
376
|
```js
|
|
350
377
|
import { SeqCrdt } from "@lazily-hub/lazily-js/seq-crdt";
|
|
@@ -360,6 +387,16 @@ peer.insert(2, "!");
|
|
|
360
387
|
text.merge(peer); // converges
|
|
361
388
|
```
|
|
362
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
|
+
|
|
363
400
|
## IPC wire types and capability negotiation
|
|
364
401
|
|
|
365
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": {
|
|
@@ -97,6 +97,11 @@
|
|
|
97
97
|
"types": "./src/lossless-tree-crdt.d.ts",
|
|
98
98
|
"default": "./src/lossless-tree-crdt.js"
|
|
99
99
|
},
|
|
100
|
+
"./indexeddb-outbox": {
|
|
101
|
+
"types": "./src/indexeddb-outbox.d.ts",
|
|
102
|
+
"browser": "./src/indexeddb-outbox.js",
|
|
103
|
+
"default": "./src/indexeddb-outbox.js"
|
|
104
|
+
},
|
|
100
105
|
"./state-projection": {
|
|
101
106
|
"types": "./src/state-projection.d.ts",
|
|
102
107
|
"default": "./src/state-projection.js"
|
|
@@ -111,7 +116,7 @@
|
|
|
111
116
|
}
|
|
112
117
|
},
|
|
113
118
|
"scripts": {
|
|
114
|
-
"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/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",
|
|
115
120
|
"test:formal": "node scripts/formal-check.mjs",
|
|
116
121
|
"bench": "node bench/context.bench.mjs",
|
|
117
122
|
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
@@ -125,6 +130,7 @@
|
|
|
125
130
|
"devDependencies": {
|
|
126
131
|
"@types/node": "^22.0.0",
|
|
127
132
|
"ajv": "^8.20.0",
|
|
133
|
+
"fake-indexeddb": "^6.2.5",
|
|
128
134
|
"tsx": "^4.19.0",
|
|
129
135
|
"typescript": "^5.6.0"
|
|
130
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/queue.d.ts
CHANGED
|
@@ -87,5 +87,101 @@ export class QueueCell {
|
|
|
87
87
|
isFull(): boolean;
|
|
88
88
|
isClosed(): boolean;
|
|
89
89
|
capacity(): number | null;
|
|
90
|
-
|
|
90
|
+
elements(): unknown[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type TopicDurabilityLabel = "durable" | "ephemeral";
|
|
94
|
+
export const TopicDurability: Readonly<{ Durable: "durable"; Ephemeral: "ephemeral" }>;
|
|
95
|
+
|
|
96
|
+
export type TopicSubscribeOutcomeLabel = "Created" | "Reconnected" | "AlreadyConnected";
|
|
97
|
+
export const TopicSubscribeOutcome: Readonly<{
|
|
98
|
+
Created: "Created";
|
|
99
|
+
Reconnected: "Reconnected";
|
|
100
|
+
AlreadyConnected: "AlreadyConnected";
|
|
101
|
+
}>;
|
|
102
|
+
|
|
103
|
+
export type TopicSubscriptionSnapshot = {
|
|
104
|
+
cursor: number;
|
|
105
|
+
durability: TopicDurabilityLabel;
|
|
106
|
+
connected: boolean;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type TopicInitial = {
|
|
110
|
+
base_offset?: number;
|
|
111
|
+
elements?: unknown[];
|
|
112
|
+
subscriptions?: Record<string, TopicSubscriptionSnapshot>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type TopicMutationResult = {
|
|
116
|
+
returns: unknown;
|
|
117
|
+
invalidates: Record<string, boolean>;
|
|
118
|
+
offset?: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Broadcast log with independent, non-destructive subscriber cursors. */
|
|
122
|
+
export class TopicCell {
|
|
123
|
+
constructor(initial?: TopicInitial);
|
|
124
|
+
static from(initial?: TopicInitial): TopicCell;
|
|
125
|
+
subscribe(id: string, durability: TopicDurabilityLabel): TopicMutationResult;
|
|
126
|
+
reconnect(id: string): TopicMutationResult;
|
|
127
|
+
disconnect(id: string): TopicMutationResult;
|
|
128
|
+
publish(value: unknown): TopicMutationResult;
|
|
129
|
+
readStream(id: string): unknown[];
|
|
130
|
+
read(id: string): unknown;
|
|
131
|
+
advance(id: string): TopicMutationResult;
|
|
132
|
+
restart(id: string): TopicMutationResult;
|
|
133
|
+
gc(): TopicMutationResult;
|
|
134
|
+
baseOffset(): number;
|
|
135
|
+
endOffset(): number;
|
|
136
|
+
elements(): unknown[];
|
|
137
|
+
subscription(id: string): TopicSubscriptionSnapshot | null;
|
|
138
|
+
subscriptions(): Record<string, TopicSubscriptionSnapshot>;
|
|
139
|
+
snapshot(): Required<TopicInitial>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type WorkQueueInvalidates = {
|
|
143
|
+
pending_len: boolean;
|
|
144
|
+
is_empty: boolean;
|
|
145
|
+
in_flight_len: boolean;
|
|
146
|
+
dead_letter_len: boolean;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type WorkQueueItem<T = unknown> = {item_id: number; value: T; attempts: number};
|
|
150
|
+
|
|
151
|
+
export type WorkQueueDelivery<T = unknown> = {
|
|
152
|
+
delivery_id: number;
|
|
153
|
+
item_id: number;
|
|
154
|
+
value: T;
|
|
155
|
+
worker: string;
|
|
156
|
+
attempt: number;
|
|
157
|
+
deadline: number;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type WorkQueueDeadLetter<T = unknown> = {
|
|
161
|
+
item_id: number;
|
|
162
|
+
value: T;
|
|
163
|
+
attempts: number;
|
|
164
|
+
reason: "nack" | "expired";
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const WorkQueueDeadLetterReason: Readonly<{Nack: "nack"; Expired: "expired"}>;
|
|
168
|
+
|
|
169
|
+
/** Pull-based competing-consumer work queue with exclusive delivery leases. */
|
|
170
|
+
export class WorkQueueCell<T = unknown> {
|
|
171
|
+
constructor(config: {visibility_timeout: number; max_deliveries: number});
|
|
172
|
+
push(value: T): {returns: number; invalidates: WorkQueueInvalidates};
|
|
173
|
+
claim(worker: string, now: number): {
|
|
174
|
+
returns: WorkQueueDelivery<T> | null;
|
|
175
|
+
invalidates: WorkQueueInvalidates;
|
|
176
|
+
};
|
|
177
|
+
ack(worker: string, deliveryId: number): {returns: boolean; invalidates: WorkQueueInvalidates};
|
|
178
|
+
nack(worker: string, deliveryId: number): {returns: boolean; invalidates: WorkQueueInvalidates};
|
|
179
|
+
reapExpired(now: number): {returns: number; invalidates: WorkQueueInvalidates};
|
|
180
|
+
pendingLen(): number;
|
|
181
|
+
isEmpty(): boolean;
|
|
182
|
+
inFlightLen(): number;
|
|
183
|
+
deadLetterLen(): number;
|
|
184
|
+
pendingItems(): WorkQueueItem<T>[];
|
|
185
|
+
inFlightDeliveries(): WorkQueueDelivery<T>[];
|
|
186
|
+
deadLetterItems(): WorkQueueDeadLetter<T>[];
|
|
91
187
|
}
|
package/src/queue.js
CHANGED
|
@@ -342,23 +342,400 @@ export class QueueCell {
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
// ---------------------------------------------------------------------------
|
|
345
|
-
//
|
|
345
|
+
// TopicCell — broadcast log with independent subscriber cursors (#lztopiccell)
|
|
346
346
|
// ---------------------------------------------------------------------------
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
347
|
+
|
|
348
|
+
export const TopicDurability = Object.freeze({
|
|
349
|
+
Durable: "durable",
|
|
350
|
+
Ephemeral: "ephemeral",
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export const TopicSubscribeOutcome = Object.freeze({
|
|
354
|
+
Created: "Created",
|
|
355
|
+
Reconnected: "Reconnected",
|
|
356
|
+
AlreadyConnected: "AlreadyConnected",
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Broadcast topic: every subscriber receives every published element using an
|
|
361
|
+
* independent, non-destructive cursor. Mutating operations return the exact
|
|
362
|
+
* per-subscriber invalidation matrix used by the canonical fixtures.
|
|
363
|
+
*/
|
|
364
|
+
export class TopicCell {
|
|
365
|
+
/**
|
|
366
|
+
* @param {{
|
|
367
|
+
* base_offset?: number,
|
|
368
|
+
* elements?: unknown[],
|
|
369
|
+
* subscriptions?: Record<string, {cursor: number, durability: "durable" | "ephemeral", connected: boolean}>
|
|
370
|
+
* }} [initial]
|
|
371
|
+
*/
|
|
372
|
+
constructor(initial = {}) {
|
|
373
|
+
this.#baseOffset = initial.base_offset ?? 0;
|
|
374
|
+
this.#elements = Array.from(initial.elements ?? []);
|
|
375
|
+
this.#subscriptions = new Map();
|
|
376
|
+
if (!Number.isSafeInteger(this.#baseOffset) || this.#baseOffset < 0) {
|
|
377
|
+
throw new RangeError("TopicCell base_offset must be a non-negative safe integer");
|
|
378
|
+
}
|
|
379
|
+
const end = this.endOffset();
|
|
380
|
+
for (const [id, raw] of Object.entries(initial.subscriptions ?? {})) {
|
|
381
|
+
if (!Number.isSafeInteger(raw.cursor) || raw.cursor < this.#baseOffset || raw.cursor > end) {
|
|
382
|
+
throw new RangeError(`TopicCell cursor for ${id} is outside the retained offset range`);
|
|
383
|
+
}
|
|
384
|
+
if (raw.durability !== TopicDurability.Durable && raw.durability !== TopicDurability.Ephemeral) {
|
|
385
|
+
throw new TypeError(`invalid TopicCell durability for ${id}`);
|
|
386
|
+
}
|
|
387
|
+
if (typeof raw.connected !== "boolean") {
|
|
388
|
+
throw new TypeError(`TopicCell connected flag for ${id} must be boolean`);
|
|
389
|
+
}
|
|
390
|
+
if (raw.durability === TopicDurability.Ephemeral && !raw.connected) {
|
|
391
|
+
throw new TypeError(`disconnected ephemeral TopicCell subscription ${id} must be removed`);
|
|
392
|
+
}
|
|
393
|
+
this.#subscriptions.set(id, {
|
|
394
|
+
cursor: raw.cursor,
|
|
395
|
+
durability: raw.durability,
|
|
396
|
+
connected: raw.connected,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#baseOffset;
|
|
402
|
+
#elements;
|
|
403
|
+
#subscriptions;
|
|
404
|
+
|
|
405
|
+
static from(initial = {}) {
|
|
406
|
+
return new TopicCell(initial);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#allFalse() {
|
|
410
|
+
return Object.fromEntries(Array.from(this.#subscriptions.keys(), (id) => [id, false]));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#only(id, changed) {
|
|
414
|
+
const invalidates = this.#allFalse();
|
|
415
|
+
invalidates[id] = changed;
|
|
416
|
+
return invalidates;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Create at tail, or reconnect an existing durable identity in place. */
|
|
420
|
+
subscribe(id, durability) {
|
|
421
|
+
const existing = this.#subscriptions.get(id);
|
|
422
|
+
if (existing) {
|
|
423
|
+
if (existing.connected) {
|
|
424
|
+
return {
|
|
425
|
+
returns: TopicSubscribeOutcome.AlreadyConnected,
|
|
426
|
+
invalidates: this.#allFalse(),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
existing.connected = true;
|
|
430
|
+
return {
|
|
431
|
+
returns: TopicSubscribeOutcome.Reconnected,
|
|
432
|
+
invalidates: this.#only(id, true),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (durability !== TopicDurability.Durable && durability !== TopicDurability.Ephemeral) {
|
|
436
|
+
throw new TypeError(`invalid TopicCell durability for ${id}`);
|
|
437
|
+
}
|
|
438
|
+
this.#subscriptions.set(id, {
|
|
439
|
+
cursor: this.endOffset(),
|
|
440
|
+
durability,
|
|
441
|
+
connected: true,
|
|
442
|
+
});
|
|
443
|
+
return { returns: TopicSubscribeOutcome.Created, invalidates: this.#only(id, true) };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Reconnect a durable identity; unknown ids are created at the current tail. */
|
|
447
|
+
reconnect(id) {
|
|
448
|
+
return this.subscribe(id, TopicDurability.Durable);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Durable ids remain offline; ephemeral ids are removed. */
|
|
452
|
+
disconnect(id) {
|
|
453
|
+
const sub = this.#subscriptions.get(id);
|
|
454
|
+
if (!sub || !sub.connected) {
|
|
455
|
+
return { returns: null, invalidates: this.#allFalse() };
|
|
456
|
+
}
|
|
457
|
+
const invalidates = this.#only(id, true);
|
|
458
|
+
if (sub.durability === TopicDurability.Ephemeral) {
|
|
459
|
+
this.#subscriptions.delete(id);
|
|
460
|
+
} else {
|
|
461
|
+
sub.connected = false;
|
|
462
|
+
}
|
|
463
|
+
return { returns: null, invalidates };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Append one element without moving any cursor. */
|
|
467
|
+
publish(value) {
|
|
468
|
+
const offset = this.endOffset();
|
|
469
|
+
this.#elements.push(value);
|
|
470
|
+
const invalidates = Object.fromEntries(
|
|
471
|
+
Array.from(this.#subscriptions, ([id, sub]) => [
|
|
472
|
+
id,
|
|
473
|
+
sub.connected && sub.cursor <= offset,
|
|
474
|
+
]),
|
|
475
|
+
);
|
|
476
|
+
return { returns: null, offset, invalidates };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Unread suffix for a connected subscriber. */
|
|
480
|
+
readStream(id) {
|
|
481
|
+
const sub = this.#subscriptions.get(id);
|
|
482
|
+
if (!sub || !sub.connected) return [];
|
|
483
|
+
return this.#elements.slice(Math.max(0, sub.cursor - this.#baseOffset));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Element at a subscriber cursor, or null at the tail/offline. */
|
|
487
|
+
read(id) {
|
|
488
|
+
return this.readStream(id)[0] ?? null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Advance only the named connected cursor by one. */
|
|
492
|
+
advance(id) {
|
|
493
|
+
const sub = this.#subscriptions.get(id);
|
|
494
|
+
if (!sub || !sub.connected || sub.cursor >= this.endOffset()) {
|
|
495
|
+
return { returns: null, invalidates: this.#allFalse() };
|
|
496
|
+
}
|
|
497
|
+
const value = this.#elements[sub.cursor - this.#baseOffset];
|
|
498
|
+
sub.cursor += 1;
|
|
499
|
+
return { returns: value, invalidates: this.#only(id, true) };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Process restart is observational: persisted durable state is unchanged. */
|
|
503
|
+
restart(_id) {
|
|
504
|
+
return { returns: null, invalidates: this.#allFalse() };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Remove only the prefix below the minimum durable absolute cursor. */
|
|
508
|
+
gc() {
|
|
509
|
+
const durable = Array.from(this.#subscriptions.values())
|
|
510
|
+
.filter((sub) => sub.durability === TopicDurability.Durable)
|
|
511
|
+
.map((sub) => sub.cursor);
|
|
512
|
+
const frontier = durable.length === 0 ? this.endOffset() : Math.min(...durable);
|
|
513
|
+
const removed = frontier - this.#baseOffset;
|
|
514
|
+
this.#elements.splice(0, removed);
|
|
515
|
+
this.#baseOffset = frontier;
|
|
516
|
+
return { returns: removed, invalidates: this.#allFalse() };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
baseOffset() {
|
|
520
|
+
return this.#baseOffset;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
endOffset() {
|
|
524
|
+
return this.#baseOffset + this.#elements.length;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
elements() {
|
|
528
|
+
return Array.from(this.#elements);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
subscription(id) {
|
|
532
|
+
const sub = this.#subscriptions.get(id);
|
|
533
|
+
return sub ? { ...sub } : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
subscriptions() {
|
|
537
|
+
return Object.fromEntries(Array.from(this.#subscriptions, ([id, sub]) => [id, { ...sub }]));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
snapshot() {
|
|
541
|
+
return {
|
|
542
|
+
base_offset: this.#baseOffset,
|
|
543
|
+
elements: this.elements(),
|
|
544
|
+
subscriptions: this.subscriptions(),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// WorkQueueCell — competing consumers with exclusive leases (#lzworkqueue)
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
export const WorkQueueDeadLetterReason = Object.freeze({
|
|
554
|
+
Nack: "nack",
|
|
555
|
+
Expired: "expired",
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Pull-based competing-consumer work queue.
|
|
560
|
+
*
|
|
561
|
+
* This is the portable local-authority lifecycle. The owning instance
|
|
562
|
+
* serializes `claim`; a distributed/HA host puts that decision behind its
|
|
563
|
+
* leader or consensus log while preserving the same operation outcomes.
|
|
564
|
+
*/
|
|
565
|
+
export class WorkQueueCell {
|
|
566
|
+
/** @param {{visibility_timeout: number, max_deliveries: number}} config */
|
|
567
|
+
constructor(config) {
|
|
568
|
+
if (!config || !Number.isSafeInteger(config.visibility_timeout) || config.visibility_timeout <= 0) {
|
|
569
|
+
throw new RangeError("visibility_timeout must be a positive safe integer");
|
|
570
|
+
}
|
|
571
|
+
if (!Number.isSafeInteger(config.max_deliveries) || config.max_deliveries < 1) {
|
|
572
|
+
throw new RangeError("max_deliveries must be at least one");
|
|
573
|
+
}
|
|
574
|
+
this.#visibilityTimeout = config.visibility_timeout;
|
|
575
|
+
this.#maxDeliveries = config.max_deliveries;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
#visibilityTimeout;
|
|
579
|
+
#maxDeliveries;
|
|
580
|
+
#pending = [];
|
|
581
|
+
#inFlight = new Map();
|
|
582
|
+
#deadLetters = [];
|
|
583
|
+
#nextItemId = 0;
|
|
584
|
+
#nextDeliveryId = 0;
|
|
585
|
+
|
|
586
|
+
#counts() {
|
|
587
|
+
return {
|
|
588
|
+
pending: this.#pending.length,
|
|
589
|
+
in_flight: this.#inFlight.size,
|
|
590
|
+
dead_letters: this.#deadLetters.length,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
#invalidates(before) {
|
|
595
|
+
const after = this.#counts();
|
|
596
|
+
return {
|
|
597
|
+
pending_len: before.pending !== after.pending,
|
|
598
|
+
is_empty: (before.pending === 0) !== (after.pending === 0),
|
|
599
|
+
in_flight_len: before.in_flight !== after.in_flight,
|
|
600
|
+
dead_letter_len: before.dead_letters !== after.dead_letters,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
#unchanged() {
|
|
605
|
+
return {
|
|
606
|
+
pending_len: false,
|
|
607
|
+
is_empty: false,
|
|
608
|
+
in_flight_len: false,
|
|
609
|
+
dead_letter_len: false,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#validateNow(now) {
|
|
614
|
+
if (!Number.isSafeInteger(now) || now < 0) {
|
|
615
|
+
throw new RangeError("now must be a non-negative safe integer");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#fail(delivery, reason) {
|
|
620
|
+
if (delivery.attempt < this.#maxDeliveries) {
|
|
621
|
+
this.#pending.push({
|
|
622
|
+
item_id: delivery.item_id,
|
|
623
|
+
value: delivery.value,
|
|
624
|
+
attempts: delivery.attempt,
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
this.#deadLetters.push({
|
|
628
|
+
item_id: delivery.item_id,
|
|
629
|
+
value: delivery.value,
|
|
630
|
+
attempts: delivery.attempt,
|
|
631
|
+
reason,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Append a pending item and return its stable item id. */
|
|
637
|
+
push(value) {
|
|
638
|
+
const before = this.#counts();
|
|
639
|
+
if (this.#nextItemId >= Number.MAX_SAFE_INTEGER) throw new RangeError("item id exhausted");
|
|
640
|
+
const itemId = this.#nextItemId;
|
|
641
|
+
this.#nextItemId += 1;
|
|
642
|
+
this.#pending.push({ item_id: itemId, value, attempts: 0 });
|
|
643
|
+
return { returns: itemId, invalidates: this.#invalidates(before) };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Claim the oldest pending item for one worker. */
|
|
647
|
+
claim(worker, now) {
|
|
648
|
+
this.#validateNow(now);
|
|
649
|
+
if (this.#pending.length === 0) {
|
|
650
|
+
return { returns: null, invalidates: this.#unchanged() };
|
|
651
|
+
}
|
|
652
|
+
if (this.#nextDeliveryId >= Number.MAX_SAFE_INTEGER) throw new RangeError("delivery id exhausted");
|
|
653
|
+
const before = this.#counts();
|
|
654
|
+
const item = this.#pending.shift();
|
|
655
|
+
const deliveryId = this.#nextDeliveryId;
|
|
656
|
+
this.#nextDeliveryId += 1;
|
|
657
|
+
const delivery = {
|
|
658
|
+
delivery_id: deliveryId,
|
|
659
|
+
item_id: item.item_id,
|
|
660
|
+
value: item.value,
|
|
661
|
+
worker,
|
|
662
|
+
attempt: item.attempts + 1,
|
|
663
|
+
deadline: Math.min(Number.MAX_SAFE_INTEGER, now + this.#visibilityTimeout),
|
|
664
|
+
};
|
|
665
|
+
this.#inFlight.set(deliveryId, delivery);
|
|
666
|
+
return { returns: { ...delivery }, invalidates: this.#invalidates(before) };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Ack only the exact current delivery owned by `worker`. */
|
|
670
|
+
ack(worker, deliveryId) {
|
|
671
|
+
const delivery = this.#inFlight.get(deliveryId);
|
|
672
|
+
if (!delivery || delivery.worker !== worker) {
|
|
673
|
+
return { returns: false, invalidates: this.#unchanged() };
|
|
674
|
+
}
|
|
675
|
+
const before = this.#counts();
|
|
676
|
+
this.#inFlight.delete(deliveryId);
|
|
677
|
+
return { returns: true, invalidates: this.#invalidates(before) };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Nack a delivery, requeueing at the tail or dead-lettering at the limit. */
|
|
681
|
+
nack(worker, deliveryId) {
|
|
682
|
+
const delivery = this.#inFlight.get(deliveryId);
|
|
683
|
+
if (!delivery || delivery.worker !== worker) {
|
|
684
|
+
return { returns: false, invalidates: this.#unchanged() };
|
|
685
|
+
}
|
|
686
|
+
const before = this.#counts();
|
|
687
|
+
this.#inFlight.delete(deliveryId);
|
|
688
|
+
this.#fail(delivery, WorkQueueDeadLetterReason.Nack);
|
|
689
|
+
return { returns: true, invalidates: this.#invalidates(before) };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Expire every lease with `deadline < now`, in delivery-id order. */
|
|
693
|
+
reapExpired(now) {
|
|
694
|
+
this.#validateNow(now);
|
|
695
|
+
const expired = Array.from(this.#inFlight.values())
|
|
696
|
+
.filter((delivery) => delivery.deadline < now)
|
|
697
|
+
.sort((a, b) => a.delivery_id - b.delivery_id);
|
|
698
|
+
if (expired.length === 0) {
|
|
699
|
+
return { returns: 0, invalidates: this.#unchanged() };
|
|
700
|
+
}
|
|
701
|
+
const before = this.#counts();
|
|
702
|
+
for (const delivery of expired) {
|
|
703
|
+
this.#inFlight.delete(delivery.delivery_id);
|
|
704
|
+
this.#fail(delivery, WorkQueueDeadLetterReason.Expired);
|
|
705
|
+
}
|
|
706
|
+
return { returns: expired.length, invalidates: this.#invalidates(before) };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
pendingLen() {
|
|
710
|
+
return this.#pending.length;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
isEmpty() {
|
|
714
|
+
return this.#pending.length === 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
inFlightLen() {
|
|
718
|
+
return this.#inFlight.size;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
deadLetterLen() {
|
|
722
|
+
return this.#deadLetters.length;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
pendingItems() {
|
|
726
|
+
return this.#pending.map((item) => ({ ...item }));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
inFlightDeliveries() {
|
|
730
|
+
return Array.from(this.#inFlight.values(), (delivery) => ({ ...delivery })).sort(
|
|
731
|
+
(a, b) => a.delivery_id - b.delivery_id,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
deadLetterItems() {
|
|
736
|
+
return this.#deadLetters.map((item) => ({ ...item }));
|
|
737
|
+
}
|
|
738
|
+
}
|
|
362
739
|
|
|
363
740
|
// ---------------------------------------------------------------------------
|
|
364
741
|
// helpers
|
package/src/text-crdt.d.ts
CHANGED
|
@@ -5,7 +5,22 @@ export class OpId {
|
|
|
5
5
|
compareTo(other: OpId): number;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export interface CrdtTree<VersionVector, Delta, Value, Self = unknown> {
|
|
9
|
+
versionVector(): VersionVector;
|
|
10
|
+
deltaSince(theirVersion: VersionVector): Delta;
|
|
11
|
+
applyDelta(delta: Delta): boolean;
|
|
12
|
+
value(): Value;
|
|
13
|
+
mergeFrom(other: Self): boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TextOp {
|
|
17
|
+
id: { counter: number; peer: number };
|
|
18
|
+
ch: string;
|
|
19
|
+
origin: { counter: number; peer: number } | null;
|
|
20
|
+
deleted: { counter: number; peer: number } | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TextCrdt implements CrdtTree<Record<number, number>, TextOp[], string, TextCrdt> {
|
|
9
24
|
constructor(peer: number);
|
|
10
25
|
static fromStr(peer: number, str: string): TextCrdt;
|
|
11
26
|
fork(peer: number): TextCrdt;
|
|
@@ -19,5 +34,10 @@ export class TextCrdt {
|
|
|
19
34
|
tombstoneCount(): number;
|
|
20
35
|
clock(): OpId;
|
|
21
36
|
merge(other: TextCrdt): boolean;
|
|
37
|
+
value(): string;
|
|
38
|
+
mergeFrom(other: TextCrdt): boolean;
|
|
39
|
+
versionVector(): Record<number, number>;
|
|
40
|
+
deltaSince(theirVersion: Record<number, number>): TextOp[];
|
|
41
|
+
applyDelta(delta: TextOp[]): boolean;
|
|
22
42
|
gcWith(isStable: (deleteOpId: OpId) => boolean): number;
|
|
23
43
|
}
|
package/src/text-crdt.js
CHANGED
|
@@ -213,6 +213,16 @@ export class TextCrdt {
|
|
|
213
213
|
return this.text() !== before;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// CrdtTree materialized-value surface.
|
|
217
|
+
value() {
|
|
218
|
+
return this.text();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// CrdtTree state-join surface.
|
|
222
|
+
mergeFrom(other) {
|
|
223
|
+
return this.merge(other);
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
// Tombstone GC: collect a stable deleted element only when nothing references
|
|
217
227
|
// it as a left origin. Bottom-up: pass 1 collects unreferenced leaf
|
|
218
228
|
// tombstones, removing a leaf un-references its origin, so further passes
|