@lazily-hub/lazily-js 0.6.1 → 0.7.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/.github/workflows/release.yml +16 -8
- package/README.md +1 -1
- package/package.json +11 -2
- package/src/queue.d.ts +91 -0
- package/src/queue.js +354 -0
|
@@ -39,9 +39,13 @@ jobs:
|
|
|
39
39
|
node-version: 24
|
|
40
40
|
registry-url: https://registry.npmjs.org
|
|
41
41
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
# OIDC trusted publishing (provenance) needs npm >= 9.5.0; Node 24's
|
|
43
|
+
# bundled npm 11.x already supports it. Do NOT `npm install -g npm@latest`
|
|
44
|
+
# — that global self-upgrade drops the `sigstore` dependency that
|
|
45
|
+
# libnpmpublish requires for provenance, producing
|
|
46
|
+
# `Cannot find module 'sigstore'` at publish time.
|
|
47
|
+
- name: Verify npm supports OIDC provenance
|
|
48
|
+
run: npm --version
|
|
45
49
|
|
|
46
50
|
- name: Install dependencies
|
|
47
51
|
working-directory: lazily-js
|
|
@@ -51,15 +55,19 @@ jobs:
|
|
|
51
55
|
working-directory: lazily-js
|
|
52
56
|
run: npm test --if-present
|
|
53
57
|
|
|
54
|
-
#
|
|
55
|
-
# `
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
+
# Publish as `latest`. The accidental v1.x versions that forced the old
|
|
59
|
+
# `prev` dist-tag workaround have been removed (npm now shows only the
|
|
60
|
+
# 0.4.0 / 0.5.0 / 0.6.x line, latest = 0.6.1), so the v0.x line is the
|
|
61
|
+
# canonical latest again.
|
|
58
62
|
- name: Publish
|
|
59
63
|
working-directory: lazily-js
|
|
60
64
|
run: |
|
|
61
65
|
version="$(node -p "require('./package.json').version")"
|
|
62
|
-
|
|
66
|
+
# --provenance triggers the OIDC trusted-publishing exchange (the
|
|
67
|
+
# `id-token: write` permission minted a short-lived publish token).
|
|
68
|
+
# The bundled npm 11.x does not auto-enable provenance the way a
|
|
69
|
+
# freshly-upgraded npm@latest does, so it must be explicit.
|
|
70
|
+
if npm publish --provenance --access public; then
|
|
63
71
|
echo "published @lazily-hub/lazily-js@${version}"
|
|
64
72
|
elif npm view "@lazily-hub/lazily-js@${version}" version >/dev/null 2>&1; then
|
|
65
73
|
echo "@lazily-hub/lazily-js@${version} already published — idempotent no-op"
|
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ notes and platform carve-outs lives in
|
|
|
46
46
|
| Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
47
47
|
| Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
48
48
|
| Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
49
|
-
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) |
|
|
49
|
+
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | — | ✅ | ✅ | — | ✅ | — | ✅ |
|
|
50
50
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
51
51
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
52
52
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), the lazily-spec IPC wire types, 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, and an FFI state-projection consumer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/lazily-hub/lazily-js.git"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"main": "src/index.js",
|
|
7
12
|
"types": "src/index.d.ts",
|
|
@@ -30,6 +35,10 @@
|
|
|
30
35
|
"types": "./src/collections.d.ts",
|
|
31
36
|
"default": "./src/collections.js"
|
|
32
37
|
},
|
|
38
|
+
"./queue": {
|
|
39
|
+
"types": "./src/queue.d.ts",
|
|
40
|
+
"default": "./src/queue.js"
|
|
41
|
+
},
|
|
33
42
|
"./sem-tree": {
|
|
34
43
|
"types": "./src/sem-tree.d.ts",
|
|
35
44
|
"default": "./src/sem-tree.js"
|
|
@@ -64,7 +73,7 @@
|
|
|
64
73
|
}
|
|
65
74
|
},
|
|
66
75
|
"scripts": {
|
|
67
|
-
"build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.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/signaling.js && node --check src/distributed.js",
|
|
76
|
+
"build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.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/signaling.js && node --check src/distributed.js",
|
|
68
77
|
"test:formal": "node scripts/formal-check.mjs",
|
|
69
78
|
"bench": "node bench/context.bench.mjs",
|
|
70
79
|
"benchmark-update": "node scripts/run-benchmarks.mjs",
|
package/src/queue.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Reactive queue: QueueCell + pluggable QueueStorage backend (#lzqueue).
|
|
2
|
+
// Pure logic — no reactive graph. See queue.js for the reader-kind invalidation
|
|
3
|
+
// contract and the shell / storage split.
|
|
4
|
+
|
|
5
|
+
export type QueuePushErrorLabel = "Full" | "Closed";
|
|
6
|
+
export type QueuePopErrorLabel = "Empty" | "Closed";
|
|
7
|
+
|
|
8
|
+
export const QueuePushError: Readonly<{ Full: "Full"; Closed: "Closed" }>;
|
|
9
|
+
export const QueuePopError: Readonly<{ Empty: "Empty"; Closed: "Closed" }>;
|
|
10
|
+
|
|
11
|
+
/** The reader-kind invalidation matrix returned by every mutating op. */
|
|
12
|
+
export type QueueInvalidates = {
|
|
13
|
+
head: boolean;
|
|
14
|
+
len: boolean;
|
|
15
|
+
is_empty: boolean;
|
|
16
|
+
is_full: boolean;
|
|
17
|
+
closed: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Result of a push op (`returns` is the error label, or `null` on success). */
|
|
21
|
+
export type QueuePushResult = {
|
|
22
|
+
returns: null | QueuePushErrorLabel;
|
|
23
|
+
invalidates: QueueInvalidates;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Result of a pop op (`returns` is the element, or the error label). */
|
|
27
|
+
export type QueuePopResult = {
|
|
28
|
+
returns: unknown | QueuePopErrorLabel;
|
|
29
|
+
invalidates: QueueInvalidates;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Result of a close op. */
|
|
33
|
+
export type QueueCloseResult = {
|
|
34
|
+
returns: null;
|
|
35
|
+
invalidates: QueueInvalidates;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A duck-typed `QueueStorage` backend. */
|
|
39
|
+
export type QueueStorage = {
|
|
40
|
+
tryPush(value: unknown): null | QueuePushErrorLabel;
|
|
41
|
+
tryPop(): unknown | QueuePopErrorLabel;
|
|
42
|
+
peek(): unknown;
|
|
43
|
+
len(): number;
|
|
44
|
+
capacity(): number | null;
|
|
45
|
+
isClosed(): boolean;
|
|
46
|
+
close(): void;
|
|
47
|
+
snapshot(): QueueStorageSnapshot;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type QueueStorageSnapshot = {
|
|
51
|
+
elements: unknown[];
|
|
52
|
+
capacity: number | null;
|
|
53
|
+
closed: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type QueueInitial = {
|
|
57
|
+
elements?: unknown[];
|
|
58
|
+
capacity?: number | null;
|
|
59
|
+
closed?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The reference `QueueStorage` backend (unbounded or bounded array FIFO). */
|
|
63
|
+
export class VecDequeStorage {
|
|
64
|
+
constructor(initial?: QueueInitial);
|
|
65
|
+
elements: unknown[];
|
|
66
|
+
static from(initial?: QueueInitial): VecDequeStorage;
|
|
67
|
+
tryPush(value: unknown): null | QueuePushErrorLabel;
|
|
68
|
+
tryPop(): unknown | QueuePopErrorLabel;
|
|
69
|
+
peek(): unknown;
|
|
70
|
+
len(): number;
|
|
71
|
+
capacity(): number | null;
|
|
72
|
+
isClosed(): boolean;
|
|
73
|
+
close(): void;
|
|
74
|
+
snapshot(): QueueStorageSnapshot;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A reactive FIFO queue — SPSC primitive with an MPSC usage rule. */
|
|
78
|
+
export class QueueCell {
|
|
79
|
+
constructor(initial?: QueueInitial, storage?: QueueStorage);
|
|
80
|
+
static from(initial?: QueueInitial, storage?: QueueStorage): QueueCell;
|
|
81
|
+
tryPush(value: unknown): QueuePushResult;
|
|
82
|
+
tryPop(): QueuePopResult;
|
|
83
|
+
close(): QueueCloseResult;
|
|
84
|
+
head(): unknown;
|
|
85
|
+
len(): number;
|
|
86
|
+
isEmpty(): boolean;
|
|
87
|
+
isFull(): boolean;
|
|
88
|
+
isClosed(): boolean;
|
|
89
|
+
capacity(): number | null;
|
|
90
|
+
elements(): unknown[];
|
|
91
|
+
}
|
package/src/queue.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// Reactive queue: QueueCell + pluggable QueueStorage backend (#lzqueue).
|
|
2
|
+
//
|
|
3
|
+
// Pure logic — no reactive graph. Like the keyed collections
|
|
4
|
+
// (`./collections.js`), this is compute that every binding MUST implement; the
|
|
5
|
+
// conformance/collections/queuecell_*.json fixtures pin behavior. To make a
|
|
6
|
+
// queue live-reactive, wrap its ops in a `Context` (cells/slots/effects) and
|
|
7
|
+
// use the returned `invalidates` matrix to drive reader-kind invalidation —
|
|
8
|
+
// see `lazily-spec/cell-model.md` § "Reactive queues" and the distributed-queue
|
|
9
|
+
// PRD for the shell / storage split.
|
|
10
|
+
//
|
|
11
|
+
// QueueCell is specified as a single-producer / single-consumer (SPSC)
|
|
12
|
+
// primitive; MPSC (multi-producer) is a *usage rule* on the same type —
|
|
13
|
+
// multiple producers push inside one logical batch, and the batch boundary
|
|
14
|
+
// serializes the pushes into a deterministic order. There is no separate
|
|
15
|
+
// MPSCQueueCell type (`lazily-spec/cell-model.md` § "QueueCell — SPSC
|
|
16
|
+
// primitive with MPSC usage rule").
|
|
17
|
+
//
|
|
18
|
+
// Invalidation is scoped to **reader kind**, not individual positions. A push
|
|
19
|
+
// invalidates `len` / `is_empty` readers (and `head` when transitioning from
|
|
20
|
+
// empty, `is_full` when transitioning onto capacity); a pop invalidates `head`
|
|
21
|
+
// / `len` / `is_empty` readers (and `is_full` when transitioning off capacity).
|
|
22
|
+
// The `invalidates` matrix returned by each mutating op reports exactly which
|
|
23
|
+
// reader kinds changed — the core reader-kind independence law, which mirrors
|
|
24
|
+
// the `PartialEq` guard the reactive bindings implement for free.
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Error sentinels — observable rejection labels (distinct observable signals).
|
|
28
|
+
// `Full` and `Closed` are the two push-rejection reasons; `Empty` and `Closed`
|
|
29
|
+
// are the two pop-rejection reasons. These match the cross-language conformance
|
|
30
|
+
// fixture `returns` labels (`lazily-spec/conformance/collections/queuecell_*`).
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export const QueuePushError = Object.freeze({
|
|
34
|
+
/** Bounded backend at capacity (reject policy on the default backend). */
|
|
35
|
+
Full: "Full",
|
|
36
|
+
/** Queue is closed; push is rejected regardless of capacity. Terminal. */
|
|
37
|
+
Closed: "Closed",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const QueuePopError = Object.freeze({
|
|
41
|
+
/** Queue is open but contains no elements. */
|
|
42
|
+
Empty: "Empty",
|
|
43
|
+
/** Queue is closed and empty (drained). Distinct from `Empty`. */
|
|
44
|
+
Closed: "Closed",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// QueueStorage contract (duck-typed).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
//
|
|
51
|
+
// The shell / storage split keeps the reactive shell storage-agnostic. The
|
|
52
|
+
// default backend is `VecDequeStorage` (unbounded or bounded array-backed
|
|
53
|
+
// FIFO). A conforming backend MUST:
|
|
54
|
+
//
|
|
55
|
+
// 1. FIFO order — `tryPop()` returns elements in `tryPush()` order.
|
|
56
|
+
// 2. Cardinality compatibility — native producer/consumer shape is a superset
|
|
57
|
+
// of SPSC; MPSC usage requires a multi-writer backend.
|
|
58
|
+
// 3. Bounded contract (optional) — `capacity()` returns a number and
|
|
59
|
+
// `tryPush()` returns `QueuePushError.Full` at capacity.
|
|
60
|
+
// 4. Position identity — invalidation is phrased over reader kind, not storage
|
|
61
|
+
// indices; the shell layers its own logical version counters above storage.
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// VecDequeStorage — the reference unbounded/bounded backend.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The reference `QueueStorage` backend: an array-backed FIFO, optionally
|
|
69
|
+
* bounded. Serializes as a JSON array (element order = FIFO order) per
|
|
70
|
+
* `lazily-spec/cell-model.md` § "Wire and snapshot shape".
|
|
71
|
+
*/
|
|
72
|
+
export class VecDequeStorage {
|
|
73
|
+
/**
|
|
74
|
+
* @param {{ elements?: unknown[], capacity?: number | null, closed?: boolean }} [initial]
|
|
75
|
+
*/
|
|
76
|
+
constructor(initial = {}) {
|
|
77
|
+
this.elements = Array.isArray(initial.elements) ? [...initial.elements] : [];
|
|
78
|
+
this.#capacity =
|
|
79
|
+
initial.capacity === undefined || initial.capacity === null
|
|
80
|
+
? null
|
|
81
|
+
: initial.capacity;
|
|
82
|
+
this.#closed = Boolean(initial.closed);
|
|
83
|
+
if (this.#capacity !== null && this.#capacity <= 0) {
|
|
84
|
+
throw new RangeError("VecDequeStorage capacity must be > 0");
|
|
85
|
+
}
|
|
86
|
+
Object.freeze(this);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#capacity;
|
|
90
|
+
#closed;
|
|
91
|
+
|
|
92
|
+
static from(initial) {
|
|
93
|
+
return new VecDequeStorage(initial);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Append `value` to the tail.
|
|
98
|
+
* @returns {null | "Full" | "Closed"} `null` on success, else the error label.
|
|
99
|
+
*/
|
|
100
|
+
tryPush(value) {
|
|
101
|
+
if (this.#closed) {
|
|
102
|
+
return QueuePushError.Closed;
|
|
103
|
+
}
|
|
104
|
+
if (this.#capacity !== null && this.elements.length >= this.#capacity) {
|
|
105
|
+
return QueuePushError.Full;
|
|
106
|
+
}
|
|
107
|
+
this.elements.push(value);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove and return the head element.
|
|
113
|
+
* @returns {unknown | "Empty" | "Closed"} the element, or the error label.
|
|
114
|
+
*/
|
|
115
|
+
tryPop() {
|
|
116
|
+
if (this.elements.length === 0) {
|
|
117
|
+
return this.#closed ? QueuePopError.Closed : QueuePopError.Empty;
|
|
118
|
+
}
|
|
119
|
+
return this.elements.shift();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @returns {unknown} the head element, or `null` when empty. */
|
|
123
|
+
peek() {
|
|
124
|
+
return this.elements.length === 0 ? null : this.elements[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @returns {number} */
|
|
128
|
+
len() {
|
|
129
|
+
return this.elements.length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {number | null} the bounded capacity, or `null` if unbounded. */
|
|
133
|
+
capacity() {
|
|
134
|
+
return this.#capacity;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {boolean} */
|
|
138
|
+
isClosed() {
|
|
139
|
+
return this.#closed;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Close the queue. Idempotent and terminal. */
|
|
143
|
+
close() {
|
|
144
|
+
this.#closed = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @returns {{ elements: unknown[], capacity: number | null, closed: boolean }} */
|
|
148
|
+
snapshot() {
|
|
149
|
+
return {
|
|
150
|
+
elements: [...this.elements],
|
|
151
|
+
capacity: this.#capacity,
|
|
152
|
+
closed: this.#closed,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// QueueCell — the reactive shell (pure logic).
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A reactive FIFO queue — SPSC primitive with an MPSC usage rule (`#lzqueue`).
|
|
163
|
+
*
|
|
164
|
+
* Pure logic: wraps a pluggable `QueueStorage` backend and, after each op,
|
|
165
|
+
* reports which reader kinds (`head` / `len` / `is_empty` / `is_full` /
|
|
166
|
+
* `closed`) changed via the returned `invalidates` matrix. Wire the matrix to a
|
|
167
|
+
* reactive `Context` to make the queue live-reactive. The reader-kind
|
|
168
|
+
* independence law — a push to a non-empty queue does NOT invalidate the `head`
|
|
169
|
+
* reader, a pop does — falls out of the value-diff this shell computes.
|
|
170
|
+
*/
|
|
171
|
+
export class QueueCell {
|
|
172
|
+
/**
|
|
173
|
+
* @param {{ elements?: unknown[], capacity?: number | null, closed?: boolean }} [initial]
|
|
174
|
+
* Passed to the default `VecDequeStorage` when no `storage` is given.
|
|
175
|
+
* @param {object} [storage] A duck-typed `QueueStorage` backend. Defaults to
|
|
176
|
+
* a `VecDequeStorage` built from `initial`.
|
|
177
|
+
*/
|
|
178
|
+
constructor(initial = {}, storage) {
|
|
179
|
+
this.#storage = storage ?? new VecDequeStorage(initial);
|
|
180
|
+
this.#prev = this.#snapshot();
|
|
181
|
+
Object.freeze(this);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#storage;
|
|
185
|
+
#prev;
|
|
186
|
+
|
|
187
|
+
static from(initial, storage) {
|
|
188
|
+
return new QueueCell(initial, storage);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// -- internal: reader-kind state + invalidation diff ----------------------
|
|
192
|
+
|
|
193
|
+
#snapshot() {
|
|
194
|
+
const len = this.#storage.len();
|
|
195
|
+
const cap = this.#storage.capacity();
|
|
196
|
+
return {
|
|
197
|
+
head: this.#storage.peek(),
|
|
198
|
+
len,
|
|
199
|
+
is_empty: len === 0,
|
|
200
|
+
is_full: cap !== null && len >= cap,
|
|
201
|
+
closed: this.#storage.isClosed(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#diff(next) {
|
|
206
|
+
const prev = this.#prev;
|
|
207
|
+
const invalidates = {
|
|
208
|
+
head: !deepEqual(prev.head, next.head),
|
|
209
|
+
len: prev.len !== next.len,
|
|
210
|
+
is_empty: prev.is_empty !== next.is_empty,
|
|
211
|
+
is_full: prev.is_full !== next.is_full,
|
|
212
|
+
closed: prev.closed !== next.closed,
|
|
213
|
+
};
|
|
214
|
+
this.#prev = next;
|
|
215
|
+
return invalidates;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// -- mutating ops ---------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Append `value` to the tail.
|
|
222
|
+
* @returns {{ returns: null | "Full" | "Closed", invalidates: QueueInvalidates }}
|
|
223
|
+
* On rejection (`Full` / `Closed`) the queue state is unchanged and the
|
|
224
|
+
* `invalidates` matrix is all-false.
|
|
225
|
+
*/
|
|
226
|
+
tryPush(value) {
|
|
227
|
+
const err = this.#storage.tryPush(value);
|
|
228
|
+
if (err !== null) {
|
|
229
|
+
return { returns: err, invalidates: emptyInvalidates() };
|
|
230
|
+
}
|
|
231
|
+
return { returns: null, invalidates: this.#diff(this.#snapshot()) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Remove and return the head element. Pop on a closed *non-empty* queue
|
|
236
|
+
* drains (returns the next element); only closed+empty yields `Closed`.
|
|
237
|
+
* @returns {{ returns: unknown | "Empty" | "Closed", invalidates: QueueInvalidates }}
|
|
238
|
+
*/
|
|
239
|
+
tryPop() {
|
|
240
|
+
const value = this.#storage.tryPop();
|
|
241
|
+
if (value === QueuePopError.Empty || value === QueuePopError.Closed) {
|
|
242
|
+
return { returns: value, invalidates: emptyInvalidates() };
|
|
243
|
+
}
|
|
244
|
+
return { returns: value, invalidates: this.#diff(this.#snapshot()) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Close the queue. Idempotent (no-op on an already-closed queue) and
|
|
249
|
+
* terminal. After close, `tryPush` returns `Closed`; `tryPop` drains and
|
|
250
|
+
* returns `Closed` only once empty.
|
|
251
|
+
* @returns {{ returns: null, invalidates: QueueInvalidates }}
|
|
252
|
+
* The `closed` reader is invalidated only on the open → closed transition.
|
|
253
|
+
*/
|
|
254
|
+
close() {
|
|
255
|
+
if (this.#storage.isClosed()) {
|
|
256
|
+
return { returns: null, invalidates: emptyInvalidates() };
|
|
257
|
+
}
|
|
258
|
+
this.#storage.close();
|
|
259
|
+
return { returns: null, invalidates: this.#diff(this.#snapshot()) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// -- reader-kind reads (current state, non-mutating) ----------------------
|
|
263
|
+
|
|
264
|
+
/** Current head value, or `null` when empty. */
|
|
265
|
+
head() {
|
|
266
|
+
return this.#storage.peek();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Number of buffered elements. */
|
|
270
|
+
len() {
|
|
271
|
+
return this.#storage.len();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Whether the queue is empty. */
|
|
275
|
+
isEmpty() {
|
|
276
|
+
return this.#storage.len() === 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether the queue is at capacity (the backpressure signal). Always `false`
|
|
281
|
+
* for an unbounded backend.
|
|
282
|
+
*/
|
|
283
|
+
isFull() {
|
|
284
|
+
const cap = this.#storage.capacity();
|
|
285
|
+
return cap !== null && this.#storage.len() >= cap;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether the queue has been closed. */
|
|
289
|
+
isClosed() {
|
|
290
|
+
return this.#storage.isClosed();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The backend's capacity, or `null` if unbounded. */
|
|
294
|
+
capacity() {
|
|
295
|
+
return this.#storage.capacity();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Snapshot the buffered elements in FIFO order. There is no reactive
|
|
300
|
+
* random-access `queue[N]` reader; per-position reactivity is the domain of
|
|
301
|
+
* `CellMap`, not `QueueCell`.
|
|
302
|
+
* @returns {unknown[]}
|
|
303
|
+
*/
|
|
304
|
+
elements() {
|
|
305
|
+
if (typeof this.#storage.elements === "function") {
|
|
306
|
+
return this.#storage.elements();
|
|
307
|
+
}
|
|
308
|
+
return this.#storage.snapshot().elements;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Future primitives (stubs) — documented, not in v1 conformance.
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
//
|
|
316
|
+
// TopicCell (SPMC broadcast / MPMC pub-sub) and WorkQueueCell (true MPMC with
|
|
317
|
+
// exclusive handoff) are genuinely distinct primitives — they differ in
|
|
318
|
+
// *invalidation model and handoff semantics*, not producer/consumer
|
|
319
|
+
// cardinality (see `lazily-spec/cell-model.md` § "Future queue primitives").
|
|
320
|
+
//
|
|
321
|
+
// - TopicCell — every subscriber receives every pushed element; each subscriber
|
|
322
|
+
// keeps its own cursor; GC bounded by the slowest cursor. Lands with the
|
|
323
|
+
// distributed-queue PRD Phase 3. Formal stub: lazily-formal/TopicCell.lean.
|
|
324
|
+
//
|
|
325
|
+
// - WorkQueueCell — N consumers compete for elements; each element delivered to
|
|
326
|
+
// exactly one consumer (exclusive handoff). Requires an authority (leader) to
|
|
327
|
+
// serialize pop-assignment; pure CRDT cannot provide it. Lands with the
|
|
328
|
+
// distributed-queue PRD Phase 2 (consensus core). Formal stub:
|
|
329
|
+
// lazily-formal/WorkQueueCell.lean.
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// helpers
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** @returns {QueueInvalidates} */
|
|
336
|
+
function emptyInvalidates() {
|
|
337
|
+
return { head: false, len: false, is_empty: false, is_full: false, closed: false };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function deepEqual(a, b) {
|
|
341
|
+
if (a === b) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const aKeys = Object.keys(a);
|
|
348
|
+
const bKeys = Object.keys(b);
|
|
349
|
+
return (
|
|
350
|
+
aKeys.length === bKeys.length &&
|
|
351
|
+
aKeys.every((k) => Object.is(aKeys[k], bKeys[k])) &&
|
|
352
|
+
aKeys.every((k) => deepEqual(a[k], b[k]))
|
|
353
|
+
);
|
|
354
|
+
}
|