@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.
@@ -39,9 +39,13 @@ jobs:
39
39
  node-version: 24
40
40
  registry-url: https://registry.npmjs.org
41
41
 
42
- # Trusted publishing needs npm CLI >= 11.5.1; Node 24's bundled npm may lag.
43
- - name: Upgrade npm for OIDC trusted publishing
44
- run: npm install -g npm@latest
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
- # Published under the `prev` dist-tag so this v0.x line does not steal
55
- # `latest` from a higher already-published version; move `latest` explicitly
56
- # (`npm dist-tag add @lazily-hub/lazily-js@<v> latest`) once the accidental
57
- # v1.x releases are removed.
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
- if npm publish --access public --tag prev; then
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.6.1",
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
+ }