articulated 0.1.0 → 0.2.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.
@@ -1,6 +1,48 @@
1
- import { equalsId } from "./id";
1
+ import { SparseIndices } from "sparse-array-rled";
2
2
  /**
3
- * A list of ElementIds.
3
+ * An inner node with inner-node children.
4
+ */
5
+ export class InnerNodeInner {
6
+ constructor(children) {
7
+ this.children = children;
8
+ let size = 0;
9
+ let knownSize = 0;
10
+ for (const child of children) {
11
+ size += child.size;
12
+ knownSize += child.knownSize;
13
+ }
14
+ this.size = size;
15
+ this.knownSize = knownSize;
16
+ }
17
+ }
18
+ /**
19
+ * An inner node with leaf children.
20
+ */
21
+ export class InnerNodeLeaf {
22
+ constructor(children) {
23
+ this.children = children;
24
+ let size = 0;
25
+ let knownSize = 0;
26
+ for (const child of children) {
27
+ size += child.present.count();
28
+ knownSize += child.count;
29
+ }
30
+ this.size = size;
31
+ this.knownSize = knownSize;
32
+ }
33
+ }
34
+ /**
35
+ * The B+Tree's branching factor, i.e., the max number of children of a node.
36
+ *
37
+ * Note that our B+Tree has no keys - in particular, no keys in internal nodes.
38
+ *
39
+ * Wiki B+Tree: "B+ trees can also be used for data stored in RAM.
40
+ * In this case a reasonable choice for block size would be the size of [the] processor's cache line."
41
+ * (64 byte cache line) / (8 byte pointer) = 8.
42
+ */
43
+ export const M = 8;
44
+ /**
45
+ * A list of ElementIds, as a persistent (immutable) data structure.
4
46
  *
5
47
  * An IdList helps you assign a unique immutable id to each element of a list, such
6
48
  * as a todo-list or a text document (= list of characters). That way, you can keep track
@@ -9,11 +51,14 @@ import { equalsId } from "./id";
9
51
  *
10
52
  * Any id that has been inserted into an IdList remains **known** to that list indefinitely,
11
53
  * allowing you to reference it in insertAfter/insertBefore operations. Calling {@link delete}
12
- * merely marks an id as deleted (not present); it remains in memory as a "tombstone".
54
+ * merely marks an id as deleted (= not present); a deleted id does not count towards the length of the list or index-based accessors, but it does remain in memory as a "tombstone".
13
55
  * This is useful in collaborative settings, since another user might instruct you to
14
56
  * call `insertAfter(before, newId)` when you have already deleted `before` locally.
15
- * If that is not a concern and you truly want to make an id no longer known, instead
16
- * call {@link uninsert}.
57
+ *
58
+ * To enable easy and efficient rollbacks, such as in a
59
+ * [server reconciliation](https://mattweidner.com/2024/06/04/server-architectures.html#1-server-reconciliation)
60
+ * architecture, IdList is a persistent (immutable) data structure. Mutating methods
61
+ * return a new IdList, sharing memory with the old IdList where possible.
17
62
  *
18
63
  * See {@link ElementId} for advice on generating ElementIds. IdList is optimized for
19
64
  * the case where sequential ElementIds often have the same bunchId and sequential counters.
@@ -21,27 +66,46 @@ import { equalsId } from "./id";
21
66
  * cause such ids to be separated, partially deleted, or even reordered.
22
67
  */
23
68
  export class IdList {
69
+ /**
70
+ * Internal - construct an IdList using a static method (e.g. `IdList.new`).
71
+ */
72
+ constructor(root) {
73
+ this.root = root;
74
+ }
24
75
  /**
25
76
  * Constructs an empty list.
26
77
  *
27
- * To begin with a non-empty list, use {@link IdList.from} or {@link IdList.fromIds}.
78
+ * To begin with a non-empty list, use {@link IdList.from}, {@link IdList.fromIds},
79
+ * or {@link IdList.load}.
28
80
  */
29
- constructor() {
30
- this.state = [];
31
- this._length = 0;
81
+ static new() {
82
+ return new this(new InnerNodeLeaf([]));
32
83
  }
33
84
  /**
34
85
  * Constructs a list with the given known ids and their isDeleted status, in list order.
35
86
  */
36
- static from(state) {
37
- const list = new IdList();
38
- for (const { id, isDeleted } of state) {
39
- // Clone to prevent aliasing.
40
- list.state.push({ id, isDeleted });
41
- if (!isDeleted)
42
- list._length++;
87
+ static from(knownIds) {
88
+ // Convert knownIds to a saved state and load that.
89
+ const savedState = [];
90
+ for (const { id, isDeleted } of knownIds) {
91
+ if (savedState.length !== 0) {
92
+ const current = savedState.at(-1);
93
+ if (id.bunchId === current.bunchId &&
94
+ id.counter === current.startCounter + current.count &&
95
+ isDeleted === current.isDeleted) {
96
+ // @ts-expect-error Mutating for convenience; no aliasing to worry about.
97
+ current.count++;
98
+ continue;
99
+ }
100
+ }
101
+ savedState.push({
102
+ bunchId: id.bunchId,
103
+ startCounter: id.counter,
104
+ count: 1,
105
+ isDeleted,
106
+ });
43
107
  }
44
- return list;
108
+ return IdList.load(savedState);
45
109
  }
46
110
  /**
47
111
  * Constructs a list with the given present ids.
@@ -51,15 +115,15 @@ export class IdList {
51
115
  * in future insertAfter/insertBefore operations.
52
116
  */
53
117
  static fromIds(ids) {
54
- const list = new IdList();
55
- for (const id of ids) {
56
- list.state.push({ id, isDeleted: false });
57
- list._length++;
58
- }
59
- return list;
118
+ return this.from((function* () {
119
+ for (const id of ids)
120
+ yield { id, isDeleted: false };
121
+ })());
60
122
  }
61
123
  /**
62
124
  * Inserts `newId` immediately after the given id (`before`), which may be deleted.
125
+ * A new IdList is returned and the current list remains unchanged.
126
+ *
63
127
  * All ids to the right of `before` are shifted one index to the right, in the manner
64
128
  * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
65
129
  *
@@ -69,28 +133,96 @@ export class IdList {
69
133
  * @param count Provide this to bulk-insert `count` ids from left-to-right,
70
134
  * starting with newId and proceeding with the same bunchId and sequential counters.
71
135
  * @throws If `before` is not known.
72
- * @throws If `newId` is already known.
136
+ * @throws If any inserted id is already known.
73
137
  */
74
138
  insertAfter(before, newId, count = 1) {
75
- if (this.isKnown(newId)) {
76
- throw new Error("newId is already known");
139
+ if (!(Number.isSafeInteger(newId.counter) && newId.counter >= 0)) {
140
+ throw new Error(`Invalid counter: ${newId.counter}`);
141
+ }
142
+ if (!(Number.isSafeInteger(count) && count >= 0)) {
143
+ throw new Error(`Invalid count: ${count}`);
144
+ }
145
+ if (count !== 0 && isAnyKnown(newId, count, this.root)) {
146
+ throw new Error("An inserted id is already known");
77
147
  }
78
- let index;
79
148
  if (before === null) {
80
- // -1 so index + 1 is 0: insert at the beginning of the list.
81
- index = -1;
149
+ if (count === 0)
150
+ return this;
151
+ if (this.root.children.length === 0) {
152
+ // Insert the first leaf as a child of root.
153
+ const present = SparseIndices.new();
154
+ present.set(newId.counter, count);
155
+ return new IdList(new InnerNodeLeaf([
156
+ {
157
+ bunchId: newId.bunchId,
158
+ startCounter: newId.counter,
159
+ count,
160
+ present,
161
+ },
162
+ ]));
163
+ }
164
+ else {
165
+ // Insert before the first known id.
166
+ return this.insertBefore(firstId(this.root), newId, count);
167
+ }
82
168
  }
83
- else {
84
- index = this.state.findIndex((elt) => equalsId(elt.id, before));
85
- if (index === -1) {
86
- throw new Error("before is not known");
169
+ const located = locate(before, this.root);
170
+ if (located === null) {
171
+ throw new Error("before is not known");
172
+ }
173
+ if (count === 0)
174
+ return this;
175
+ const leaf = located[0].node;
176
+ if (before.counter === leaf.startCounter + leaf.count - 1) {
177
+ // before is leaf's last id: we insert directly after leaf.
178
+ if (leaf.bunchId === newId.bunchId &&
179
+ leaf.startCounter + leaf.count === newId.counter) {
180
+ // Extending leaf forwards.
181
+ const present = leaf.present.clone();
182
+ present.set(newId.counter, count);
183
+ return this.replaceLeaf(located, {
184
+ ...leaf,
185
+ count: leaf.count + count,
186
+ present,
187
+ });
188
+ }
189
+ else {
190
+ const present = SparseIndices.new();
191
+ present.set(newId.counter, count);
192
+ return this.replaceLeaf(located, leaf, {
193
+ bunchId: newId.bunchId,
194
+ startCounter: newId.counter,
195
+ count,
196
+ present,
197
+ });
87
198
  }
88
199
  }
89
- this.state.splice(index + 1, 0, ...expandElements(newId, false, count));
90
- this._length += count;
200
+ else {
201
+ // before is not leaf's last id: we need to split leaf and insert there.
202
+ const newPresent = SparseIndices.new();
203
+ newPresent.set(newId.counter, count);
204
+ const [leftPresent, rightPresent] = splitPresent(leaf.present, before.counter + 1);
205
+ return this.replaceLeaf(located, {
206
+ ...leaf,
207
+ count: before.counter + 1 - leaf.startCounter,
208
+ present: leftPresent,
209
+ }, {
210
+ bunchId: newId.bunchId,
211
+ startCounter: newId.counter,
212
+ count,
213
+ present: newPresent,
214
+ }, {
215
+ ...leaf,
216
+ startCounter: before.counter + 1,
217
+ count: leaf.count - (before.counter + 1 - leaf.startCounter),
218
+ present: rightPresent,
219
+ });
220
+ }
91
221
  }
92
222
  /**
93
223
  * Inserts `newId` immediately before the given id (`after`), which may be deleted.
224
+ * A new IdList is returned and the current list remains unchanged.
225
+ *
94
226
  * All ids to the right of `after`, plus `after` itself, are shifted one index to the right, in the manner
95
227
  * of [Array.splice](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice).
96
228
  *
@@ -104,75 +236,128 @@ export class IdList {
104
236
  * @throws If `newId` is already known.
105
237
  */
106
238
  insertBefore(after, newId, count = 1) {
107
- if (this.isKnown(newId)) {
108
- throw new Error("newId is already known");
239
+ if (!(Number.isSafeInteger(newId.counter) && newId.counter >= 0)) {
240
+ throw new Error(`Invalid counter: ${newId.counter}`);
241
+ }
242
+ if (!(Number.isSafeInteger(count) && count >= 0)) {
243
+ throw new Error(`Invalid count: ${count}`);
244
+ }
245
+ if (count !== 0 && isAnyKnown(newId, count, this.root)) {
246
+ throw new Error("An inserted id is already known");
109
247
  }
110
- let index;
111
248
  if (after === null) {
112
- index = this.state.length;
249
+ if (count === 0)
250
+ return this;
251
+ // Insert after the last known id, or at the beginning if empty.
252
+ return this.insertAfter(this.root.knownSize === 0 ? null : lastId(this.root), newId, count);
113
253
  }
114
- else {
115
- index = this.state.findIndex((elt) => equalsId(elt.id, after));
116
- if (index === -1) {
117
- throw new Error("after is not known");
254
+ const located = locate(after, this.root);
255
+ if (located === null) {
256
+ throw new Error("after is not known");
257
+ }
258
+ if (count === 0)
259
+ return this;
260
+ const leaf = located[0].node;
261
+ if (after.counter === leaf.startCounter) {
262
+ // after is leaf's first id: we insert directly before leaf.
263
+ if (leaf.bunchId === newId.bunchId &&
264
+ leaf.startCounter === newId.counter + count) {
265
+ // Extending leaf backwards.
266
+ const present = leaf.present.clone();
267
+ present.set(newId.counter, count);
268
+ return this.replaceLeaf(located, {
269
+ ...leaf,
270
+ startCounter: leaf.startCounter - count,
271
+ count: leaf.count + count,
272
+ present,
273
+ });
274
+ }
275
+ else {
276
+ const present = SparseIndices.new();
277
+ present.set(newId.counter, count);
278
+ return this.replaceLeaf(located, {
279
+ bunchId: newId.bunchId,
280
+ startCounter: newId.counter,
281
+ count,
282
+ present,
283
+ }, leaf);
118
284
  }
119
285
  }
120
- // We insert the bunch from left-to-right even though it's insertBefore.
121
- this.state.splice(index, 0, ...expandElements(newId, false, count));
122
- this._length += count;
123
- }
124
- /**
125
- * Un-inserts `id` from the list, making it no longer known or present in this list.
126
- *
127
- * Typically, you instead want to call {@link delete}, which marks `id` as deleted while
128
- * it remains known. That way, you can reference `id` in future insertAfter/insertBefore
129
- * operations, including ones sent concurrently by other devices.
130
- *
131
- * If `id` is already not known, this method does nothing.
132
- */
133
- uninsert(id) {
134
- const index = this.state.findIndex((elt) => equalsId(elt.id, id));
135
- if (index !== -1) {
136
- this.state.splice(index, 1);
137
- this._length--;
286
+ else {
287
+ // after is not leaf's first id: we need to split leaf and insert there.
288
+ const present = SparseIndices.new();
289
+ present.set(newId.counter, count);
290
+ const [leftPresent, rightPresent] = splitPresent(leaf.present, after.counter);
291
+ return this.replaceLeaf(located, {
292
+ ...leaf,
293
+ count: after.counter - leaf.startCounter,
294
+ present: leftPresent,
295
+ }, {
296
+ bunchId: newId.bunchId,
297
+ startCounter: newId.counter,
298
+ count,
299
+ present,
300
+ }, {
301
+ ...leaf,
302
+ startCounter: after.counter,
303
+ count: leaf.count - (after.counter - leaf.startCounter),
304
+ present: rightPresent,
305
+ });
138
306
  }
139
307
  }
140
308
  /**
141
- * Marks `id` as deleted from this list. The id remains known (a "tombstone").
309
+ * Marks `id` as deleted from this list.
310
+ * A new IdList is returned and the current list remains unchanged.
142
311
  *
312
+ * Once deleted, `id` does not count towards the length of the list or index-based accessors.
313
+ * However, it remains known (a "tombstone").
143
314
  * Because `id` is still known, you can reference it in future insertAfter/insertBefore
144
315
  * operations, including ones sent concurrently by other devices.
145
- * However, it does occupy space in memory (compressed in common cases).
146
- *
147
- * For an exact inverse to `insertAfter(-, id)` or `insertBefore(-, id)`
148
- * that makes `id` no longer known, see {@link uninsert}.
316
+ * This does have a memory cost, but it is compressed in common cases.
149
317
  *
150
- * If `id` is already deleted or not known, this method does nothing.
318
+ * If `id` is already deleted or is not known, this method does nothing.
151
319
  */
152
320
  delete(id) {
153
- const elt = this.state.find((elt) => equalsId(elt.id, id));
154
- if (elt !== undefined && !elt.isDeleted) {
155
- elt.isDeleted = true;
156
- this._length--;
157
- }
321
+ const located = locate(id, this.root);
322
+ if (located === null)
323
+ return this;
324
+ const leaf = located[0].node;
325
+ if (!leaf.present.has(id.counter))
326
+ return this;
327
+ const newPresent = leaf.present.clone();
328
+ newPresent.delete(id.counter);
329
+ return this.replaceLeaf(located, { ...leaf, present: newPresent });
158
330
  }
159
331
  /**
160
332
  * Un-marks `id` as deleted from this list, making it present again.
161
- * This is an exact inverse to {@link delete}.
333
+ * A new IdList is returned and the current list remains unchanged.
334
+ *
335
+ * This method is an exact inverse to {@link delete}.
162
336
  *
163
337
  * If `id` is already present, this method does nothing.
164
338
  *
165
339
  * @throws If `id` is not known.
166
340
  */
167
341
  undelete(id) {
168
- const elt = this.state.find((elt) => equalsId(elt.id, id));
169
- if (elt === undefined) {
342
+ const located = locate(id, this.root);
343
+ if (located === null) {
170
344
  throw new Error("id is not known");
171
345
  }
172
- if (elt.isDeleted) {
173
- elt.isDeleted = false;
174
- this._length++;
175
- }
346
+ const leaf = located[0].node;
347
+ if (leaf.present.has(id.counter))
348
+ return this;
349
+ const newPresent = leaf.present.clone();
350
+ newPresent.set(id.counter);
351
+ return this.replaceLeaf(located, { ...leaf, present: newPresent });
352
+ }
353
+ /**
354
+ * Replaces the leaf at the given path with newLeaves.
355
+ * Returns a proper (sufficiently balanced) B+Tree with updated sizes.
356
+ *
357
+ * newLeaves.length must be in [1, M].
358
+ */
359
+ replaceLeaf(located, ...newLeaves) {
360
+ return new IdList(replaceNode(located, this.root, newLeaves, 0));
176
361
  }
177
362
  // Accessors
178
363
  /**
@@ -183,10 +368,10 @@ export class IdList {
183
368
  * Compare to {@link isKnown}.
184
369
  */
185
370
  has(id) {
186
- const elt = this.state.find((elt) => equalsId(elt.id, id));
187
- if (elt === undefined)
371
+ const located = locate(id, this.root);
372
+ if (located === null)
188
373
  return false;
189
- return !elt.isDeleted;
374
+ return located[0].node.present.has(id.counter);
190
375
  }
191
376
  /**
192
377
  * Returns whether id is known to this list.
@@ -194,7 +379,15 @@ export class IdList {
194
379
  * Compare to {@link has}.
195
380
  */
196
381
  isKnown(id) {
197
- return this.state.some((elt) => equalsId(elt.id, id));
382
+ return locate(id, this.root) !== null;
383
+ }
384
+ /**
385
+ * The length of the list, counting only present ids.
386
+ *
387
+ * To include known but deleted ids, use `this.knownIds.length`.
388
+ */
389
+ get length() {
390
+ return this.root.size;
198
391
  }
199
392
  /**
200
393
  * Returns the id at the given index in the list.
@@ -206,14 +399,38 @@ export class IdList {
206
399
  throw new Error(`Index out of bounds: ${index} (length: ${this.length}`);
207
400
  }
208
401
  let remaining = index;
209
- for (const elt of this.state) {
210
- if (!elt.isDeleted) {
211
- if (remaining === 0)
212
- return elt.id;
213
- remaining--;
402
+ let curParent = this.root;
403
+ // eslint-disable-next-line no-constant-condition
404
+ recurse: while (true) {
405
+ if (curParent instanceof InnerNodeInner) {
406
+ for (const child of curParent.children) {
407
+ if (remaining < child.size) {
408
+ // Recurse.
409
+ curParent = child;
410
+ continue recurse;
411
+ }
412
+ else {
413
+ remaining -= child.size;
414
+ }
415
+ }
214
416
  }
417
+ else {
418
+ for (const child of curParent.children) {
419
+ const childSize = child.present.count();
420
+ if (remaining < childSize) {
421
+ // Found it.
422
+ return {
423
+ bunchId: child.bunchId,
424
+ counter: child.present.indexOfCount(remaining),
425
+ };
426
+ }
427
+ else {
428
+ remaining -= childSize;
429
+ }
430
+ }
431
+ }
432
+ throw new Error("Internal error");
215
433
  }
216
- throw new Error("Internal error");
217
434
  }
218
435
  /**
219
436
  * Returns the index of `id` in the list.
@@ -227,47 +444,48 @@ export class IdList {
227
444
  * @throws If `id` is not known.
228
445
  */
229
446
  indexOf(id, bias = "none") {
447
+ const located = locate(id, this.root);
448
+ if (located === null)
449
+ throw new Error("id is not known");
230
450
  /**
231
451
  * The number of present ids less than id.
232
452
  * Equivalently, the index id would have if present.
233
453
  */
234
454
  let index = 0;
235
- for (const elt of this.state) {
236
- if (equalsId(elt.id, id)) {
237
- // Found it.
238
- if (elt.isDeleted) {
239
- switch (bias) {
240
- case "none":
241
- return -1;
242
- case "left":
243
- return index - 1;
244
- case "right":
245
- return index;
246
- }
247
- }
248
- else
455
+ // Lesser siblings of parent, grandparent, etc.
456
+ for (let i = 1; i < located.length; i++) {
457
+ const parent = (i === located.length - 1 ? this.root : located[i + 1].node);
458
+ for (let c = 0; c < located[i].indexInParent; c++) {
459
+ index += parent.children[c].size;
460
+ }
461
+ }
462
+ // Siblings of id's leaf.
463
+ const leafParent = (located.length === 1 ? this.root : located[1].node);
464
+ for (let c = 0; c < located[0].indexInParent; c++) {
465
+ index += leafParent.children[c].present.count();
466
+ }
467
+ // id's index within leaf.
468
+ const [count, has] = located[0].node.present._countHas(id.counter);
469
+ index += count;
470
+ if (has)
471
+ return index;
472
+ else {
473
+ switch (bias) {
474
+ case "none":
475
+ return -1;
476
+ case "left":
477
+ return index - 1;
478
+ case "right":
249
479
  return index;
250
480
  }
251
- if (!elt.isDeleted)
252
- index++;
253
481
  }
254
- throw new Error("id is not known");
255
- }
256
- /**
257
- * The length of the list.
258
- */
259
- get length() {
260
- return this._length;
261
482
  }
262
483
  // Iterators and views
263
484
  /**
264
485
  * Iterates over all present ids in the list.
265
486
  */
266
- *[Symbol.iterator]() {
267
- for (const elt of this.state) {
268
- if (!elt.isDeleted)
269
- yield elt.id;
270
- }
487
+ [Symbol.iterator]() {
488
+ return iterateNode(this.root, false);
271
489
  }
272
490
  /**
273
491
  * Iterates over all present ids in the list.
@@ -278,22 +496,16 @@ export class IdList {
278
496
  /**
279
497
  * Iterates over all __known__ ids in the list, indicating which are deleted.
280
498
  */
281
- valuesWithDeleted() {
282
- return this.state.values();
499
+ valuesWithIsDeleted() {
500
+ return iterateNodeWithIsDeleted(this.root);
283
501
  }
284
502
  /**
285
- * Returns an independent copy of this list, including known but deleted ids.
286
- */
287
- clone() {
288
- return IdList.from(this.state);
289
- }
290
- /**
291
- * A live-updating view of this list that treats all known ids as present.
292
- * That is, it ignores isDeleted status when computing list indices or iterating.
503
+ * A view of this list that treats all known ids as present.
504
+ * That is, it ignores is-deleted status when computing list indices or iterating.
293
505
  */
294
506
  get knownIds() {
295
507
  if (this._knownIds === undefined) {
296
- this._knownIds = new KnownIdView(this, this.state);
508
+ this._knownIds = new KnownIdView(this, this.root);
297
509
  }
298
510
  return this._knownIds;
299
511
  }
@@ -305,60 +517,79 @@ export class IdList {
305
517
  * See {@link SavedIdList} for a description of the save format.
306
518
  */
307
519
  save() {
308
- const ans = [];
309
- for (const { id, isDeleted } of this.state) {
310
- if (ans.length !== 0) {
311
- const current = ans[ans.length - 1];
312
- if (id.bunchId === current.bunchId &&
313
- id.counter === current.startCounter + current.count &&
314
- isDeleted === current.isDeleted) {
315
- current.count++;
316
- continue;
317
- }
318
- }
319
- ans.push({
320
- bunchId: id.bunchId,
321
- startCounter: id.counter,
322
- count: 1,
323
- isDeleted,
324
- });
325
- }
326
- return ans;
520
+ const acc = [];
521
+ saveNode(this.root, acc);
522
+ return acc;
327
523
  }
328
524
  /**
329
- * Loads a saved state returned by {@link save}, __overwriting__ the current state of this list.
525
+ * Loads a saved state returned by {@link save}.
330
526
  */
331
- load(savedState) {
332
- this.state.length = 0;
333
- this._length = 0;
334
- for (const { bunchId, startCounter, count, isDeleted } of savedState) {
335
- if (!(Number.isSafeInteger(count) && count >= 0)) {
336
- throw new Error(`Invalid length: ${count}`);
527
+ static load(savedState) {
528
+ // 1. Determine the leaves.
529
+ const leaves = [];
530
+ for (let i = 0; i < savedState.length; i++) {
531
+ const item = savedState[i];
532
+ if (!(Number.isSafeInteger(item.count) && item.count >= 0)) {
533
+ throw new Error(`Invalid count: ${item.count}`);
337
534
  }
338
- for (let i = 0; i < count; i++) {
339
- this.state.push({
340
- id: { bunchId, counter: startCounter + i },
341
- isDeleted,
342
- });
535
+ if (!(Number.isSafeInteger(item.startCounter) && item.startCounter >= 0)) {
536
+ throw new Error(`Invalid startCounter: ${item.startCounter}`);
537
+ }
538
+ if (item.count === 0)
539
+ continue;
540
+ if (leaves.length !== 0) {
541
+ const lastLeaf = leaves.at(-1);
542
+ if (item.bunchId === lastLeaf.bunchId &&
543
+ item.startCounter === lastLeaf.startCounter + lastLeaf.count) {
544
+ // Extend lastLeaf.
545
+ // Okay to mutate in-place since we haven't referenced it anywhere else yet.
546
+ // @ts-expect-error Mutate in place
547
+ lastLeaf.count += item.count;
548
+ if (!item.isDeleted) {
549
+ lastLeaf.present.set(item.startCounter, item.count);
550
+ }
551
+ continue;
552
+ }
343
553
  }
344
- if (!isDeleted)
345
- this._length += count;
554
+ // If we get to here, we need a new leaf.
555
+ const present = SparseIndices.new();
556
+ if (!item.isDeleted)
557
+ present.set(item.startCounter, item.count);
558
+ leaves.push({
559
+ bunchId: item.bunchId,
560
+ startCounter: item.startCounter,
561
+ count: item.count,
562
+ present,
563
+ });
346
564
  }
565
+ // 2. Create a B+Tree with the given leaves.
566
+ // We do a "direct" balanced construction that takes O(n) time, instead of inserting
567
+ // leaves one-by-one, which would take O(n log(n)) time.
568
+ if (leaves.length === 0)
569
+ return IdList.new();
570
+ // Depth of the B+Tree (number of non-root nodes on any path from a leaf to the root).
571
+ // A fully balanced B+Tree of depth d has between [M^{d-1} + 1, M^d] leaves.
572
+ const depth = leaves.length === 1
573
+ ? 1
574
+ : Math.ceil(Math.log(leaves.length) / Math.log(M));
575
+ return new IdList(buildTree(leaves, 0, depth));
347
576
  }
348
577
  }
349
578
  /**
350
- * A live-updating view of an IdList that treats all known ids as present.
351
- * That is, this class ignores the underlying list's isDeleted status when computing list indices.
579
+ * A view of an IdList that treats all known ids as present.
580
+ * That is, this class ignores the underlying list's is-deleted status when computing list indices.
581
+ * Access using {@link IdList.knownIds}.
352
582
  *
353
- * To mutate, call methods on the original IdList (`this.list`).
583
+ * Like IdList, KnownIdView is immutable. To mutate, use a mutating method on the original IdList
584
+ * and access the returned list's `knownIds`.
354
585
  */
355
586
  export class KnownIdView {
356
587
  /**
357
588
  * Internal use only. Use {@link IdList.knownIds} instead.
358
589
  */
359
- constructor(list, state) {
590
+ constructor(list, root) {
360
591
  this.list = list;
361
- this.state = state;
592
+ this.root = root;
362
593
  }
363
594
  // Mutators are omitted - mutate this.list instead.
364
595
  // Accessors
@@ -373,13 +604,66 @@ export class KnownIdView {
373
604
  if (!(Number.isSafeInteger(index) && 0 <= index && index < this.length)) {
374
605
  throw new Error(`Index out of bounds: ${index} (length: ${this.length}`);
375
606
  }
376
- return this.state[index].id;
607
+ let remaining = index;
608
+ let curParent = this.root;
609
+ // eslint-disable-next-line no-constant-condition
610
+ recurse: while (true) {
611
+ if (curParent instanceof InnerNodeInner) {
612
+ for (const child of curParent.children) {
613
+ if (remaining < child.knownSize) {
614
+ // Recurse.
615
+ curParent = child;
616
+ continue recurse;
617
+ }
618
+ else {
619
+ remaining -= child.knownSize;
620
+ }
621
+ }
622
+ }
623
+ else {
624
+ for (const child of curParent.children) {
625
+ if (remaining < child.count) {
626
+ // Found it.
627
+ return {
628
+ bunchId: child.bunchId,
629
+ counter: child.startCounter + remaining,
630
+ };
631
+ }
632
+ else {
633
+ remaining -= child.count;
634
+ }
635
+ }
636
+ }
637
+ throw new Error("Internal error");
638
+ }
377
639
  }
378
640
  /**
379
641
  * Returns the index of `id` in this view, or -1 if it is not known.
380
642
  */
381
643
  indexOf(id) {
382
- return this.state.findIndex((elt) => equalsId(elt.id, id));
644
+ const located = locate(id, this.root);
645
+ if (located === null)
646
+ throw new Error("id is not known");
647
+ /**
648
+ * The number of present ids less than id.
649
+ * Equivalently, the index id would have if present.
650
+ */
651
+ let index = 0;
652
+ // Lesser siblings of parent, grandparent, etc.
653
+ for (let i = 1; i < located.length; i++) {
654
+ const parent = (i === located.length - 1 ? this.root : located[i + 1].node);
655
+ for (let c = 0; c < located[i].indexInParent; c++) {
656
+ index += parent.children[c].knownSize;
657
+ }
658
+ }
659
+ // Siblings of id's leaf.
660
+ const leafParent = (located.length === 1 ? this.root : located[1].node);
661
+ for (let c = 0; c < located[0].indexInParent; c++) {
662
+ const child = leafParent.children[c];
663
+ index += child.count;
664
+ }
665
+ // id's index with leaf.
666
+ return index + (id.counter - located[0].node.startCounter);
383
667
  }
384
668
  /**
385
669
  * The length of this view.
@@ -387,16 +671,14 @@ export class KnownIdView {
387
671
  * Equivalently, the number of known ids in `this.list`.
388
672
  */
389
673
  get length() {
390
- return this.state.length;
674
+ return this.root.knownSize;
391
675
  }
392
676
  // Iterators
393
677
  /**
394
678
  * Iterates over all ids in this view, i.e., all known ids in `this.list`.
395
679
  */
396
- *[Symbol.iterator]() {
397
- for (const elt of this.state) {
398
- yield elt.id;
399
- }
680
+ [Symbol.iterator]() {
681
+ return iterateNode(this.root, true);
400
682
  }
401
683
  /**
402
684
  * Iterates over all ids in this view, i.e., all known ids in `this.list`.
@@ -405,17 +687,291 @@ export class KnownIdView {
405
687
  return this[Symbol.iterator]();
406
688
  }
407
689
  }
408
- function expandElements(startId, isDeleted, count) {
409
- if (!(Number.isSafeInteger(count) && count >= 0)) {
410
- throw new Error(`Invalid count: ${count}`);
411
- }
412
- const ans = [];
413
- for (let i = 0; i < count; i++) {
414
- ans.push({
415
- id: { bunchId: startId.bunchId, counter: startId.counter + i },
416
- isDeleted,
417
- });
418
- }
419
- return ans;
690
+ /**
691
+ * Returns the first (leftmost) known ElementId in node's subtree.
692
+ */
693
+ function firstId(node) {
694
+ let currentInner = node;
695
+ while (!(currentInner instanceof InnerNodeLeaf)) {
696
+ currentInner = currentInner.children[0];
697
+ }
698
+ const firstLeaf = currentInner.children[0];
699
+ return {
700
+ bunchId: firstLeaf.bunchId,
701
+ counter: firstLeaf.startCounter,
702
+ };
703
+ }
704
+ /**
705
+ * Returns the last (rightmost) known ElementId in node's subtree.
706
+ */
707
+ function lastId(node) {
708
+ let currentInner = node;
709
+ while (!(currentInner instanceof InnerNodeLeaf)) {
710
+ currentInner = currentInner.children.at(-1);
711
+ }
712
+ const lastLeaf = currentInner.children.at(-1);
713
+ return {
714
+ bunchId: lastLeaf.bunchId,
715
+ counter: lastLeaf.startCounter + lastLeaf.count - 1,
716
+ };
717
+ }
718
+ /**
719
+ * Returns the path from id's leaf node to the root, or null if id is not found.
720
+ *
721
+ * The path contains each node and its index in its parent's node, starting with id's
722
+ * LeafNode and ending at a child of the root.
723
+ */
724
+ export function locate(id, node) {
725
+ if (node instanceof InnerNodeInner) {
726
+ for (let i = 0; i < node.children.length; i++) {
727
+ const child = node.children[i];
728
+ const childLocated = locate(id, child);
729
+ if (childLocated !== null) {
730
+ childLocated.push({ node: child, indexInParent: i });
731
+ return childLocated;
732
+ }
733
+ }
734
+ }
735
+ else {
736
+ for (let i = 0; i < node.children.length; i++) {
737
+ const child = node.children[i];
738
+ if (child.bunchId === id.bunchId &&
739
+ child.startCounter <= id.counter &&
740
+ id.counter < child.startCounter + child.count) {
741
+ return [{ node: child, indexInParent: i }];
742
+ }
743
+ }
744
+ }
745
+ return null;
746
+ }
747
+ /**
748
+ * Returns true if any of the given bulk ids are known within node's subtree.
749
+ *
750
+ * Assumes count > 0.
751
+ */
752
+ function isAnyKnown(id, count, node) {
753
+ if (node instanceof InnerNodeInner) {
754
+ for (const child of node.children) {
755
+ if (isAnyKnown(id, count, child))
756
+ return true;
757
+ }
758
+ }
759
+ else {
760
+ for (const child of node.children) {
761
+ if (child.bunchId === id.bunchId) {
762
+ // Test if there is any overlap between the child's counter range [a, b]
763
+ // and the bulk id's counter range [c, d].
764
+ const a = child.startCounter;
765
+ const b = child.startCounter + child.count - 1;
766
+ const c = id.counter;
767
+ const d = id.counter + count - 1;
768
+ if (a <= d && c <= b)
769
+ return true;
770
+ }
771
+ }
772
+ }
773
+ return false;
774
+ }
775
+ /**
776
+ * Replace located[i].node with newNodes.
777
+ *
778
+ * newNodes.length must be in [1, M].
779
+ */
780
+ function replaceNode(located, root, newNodes, i) {
781
+ const parent = i === located.length - 1 ? root : located[i + 1].node;
782
+ const indexInParent = located[i].indexInParent;
783
+ // Copy-on-write version of parent.children.splice(indexInParent, 1, ...newNodes)
784
+ const newChildren = parent.children
785
+ .slice(0, indexInParent)
786
+ .concat(newNodes, parent.children.slice(indexInParent + 1));
787
+ if (newChildren.length > M) {
788
+ // Split the parent to maintain BTree property (# children <= M).
789
+ const split = Math.ceil(newChildren.length / 2);
790
+ const newParents = [
791
+ newChildren.slice(0, split),
792
+ newChildren.slice(split),
793
+ ].map((children) => i === 0
794
+ ? new InnerNodeLeaf(children)
795
+ : new InnerNodeInner(children));
796
+ if (i === located.length - 1) {
797
+ // newParents replace root. We need a new root to hold them.
798
+ return new InnerNodeInner(newParents);
799
+ }
800
+ else {
801
+ return replaceNode(located, root, newParents, i + 1);
802
+ }
803
+ }
804
+ else {
805
+ const newParent = i === 0
806
+ ? new InnerNodeLeaf(newChildren)
807
+ : new InnerNodeInner(newChildren);
808
+ if (i === located.length - 1) {
809
+ // Replaces root.
810
+ return newParent;
811
+ }
812
+ else {
813
+ return replaceNode(located, root, [newParent], i + 1);
814
+ }
815
+ }
816
+ }
817
+ /**
818
+ * Splits present into two SparseIndices at the given counter.
819
+ */
820
+ function splitPresent(present, splitCounter) {
821
+ const leftPresent = SparseIndices.new();
822
+ const rightPresent = SparseIndices.new();
823
+ const leafSlicer = present.newSlicer();
824
+ for (const [index, count] of leafSlicer.nextSlice(splitCounter)) {
825
+ leftPresent.set(index, count);
826
+ }
827
+ for (const [index, count] of leafSlicer.nextSlice(null)) {
828
+ rightPresent.set(index, count);
829
+ }
830
+ return [leftPresent, rightPresent];
831
+ }
832
+ function* iterateNode(node, includeDeleted) {
833
+ if (node instanceof InnerNodeInner) {
834
+ for (const child of node.children) {
835
+ yield* iterateNode(child, includeDeleted);
836
+ }
837
+ }
838
+ else {
839
+ for (const child of node.children) {
840
+ if (includeDeleted) {
841
+ for (let i = 0; i < child.count; i++) {
842
+ yield { bunchId: child.bunchId, counter: child.startCounter + i };
843
+ }
844
+ }
845
+ else {
846
+ for (const counter of child.present.keys()) {
847
+ yield { bunchId: child.bunchId, counter };
848
+ }
849
+ }
850
+ }
851
+ }
852
+ }
853
+ function* iterateNodeWithIsDeleted(node) {
854
+ if (node instanceof InnerNodeInner) {
855
+ for (const child of node.children) {
856
+ yield* iterateNodeWithIsDeleted(child);
857
+ }
858
+ }
859
+ else {
860
+ for (const child of node.children) {
861
+ let nextIndex = child.startCounter;
862
+ for (const index of child.present.keys()) {
863
+ while (nextIndex < index) {
864
+ yield {
865
+ id: { bunchId: child.bunchId, counter: nextIndex },
866
+ isDeleted: true,
867
+ };
868
+ nextIndex++;
869
+ }
870
+ yield {
871
+ id: { bunchId: child.bunchId, counter: index },
872
+ isDeleted: false,
873
+ };
874
+ nextIndex++;
875
+ }
876
+ while (nextIndex < child.startCounter + child.count) {
877
+ yield {
878
+ id: { bunchId: child.bunchId, counter: nextIndex },
879
+ isDeleted: true,
880
+ };
881
+ nextIndex++;
882
+ }
883
+ }
884
+ }
885
+ }
886
+ /**
887
+ * Updates acc to account for node's subtree, as part of a depth-first search
888
+ * in list order.
889
+ */
890
+ function saveNode(node, acc) {
891
+ if (node instanceof InnerNodeInner) {
892
+ for (const child of node.children) {
893
+ saveNode(child, acc);
894
+ }
895
+ }
896
+ else {
897
+ for (const child of node.children) {
898
+ let nextIndex = child.startCounter;
899
+ for (const [index, count] of child.present.items()) {
900
+ if (nextIndex < index) {
901
+ // Need a deleted item.
902
+ pushSaveItem(acc, {
903
+ bunchId: child.bunchId,
904
+ startCounter: nextIndex,
905
+ count: index - nextIndex,
906
+ isDeleted: true,
907
+ });
908
+ }
909
+ pushSaveItem(acc, {
910
+ bunchId: child.bunchId,
911
+ startCounter: index,
912
+ count,
913
+ isDeleted: false,
914
+ });
915
+ nextIndex = index + count;
916
+ }
917
+ if (nextIndex < child.startCounter + child.count) {
918
+ pushSaveItem(acc, {
919
+ bunchId: child.bunchId,
920
+ startCounter: nextIndex,
921
+ count: child.startCounter + child.count - nextIndex,
922
+ isDeleted: true,
923
+ });
924
+ }
925
+ }
926
+ }
927
+ }
928
+ /**
929
+ * Pushes a save item onto acc, combing it with the previous item if possible.
930
+ *
931
+ * This function is necessary because we don't guarantee that adjacent leaves are fully merged.
932
+ * Specifically, if you insert a bunch's ids with counter values (0, 2, 1)
933
+ * in that order, then counter 1 will extend one of the existing leaves
934
+ * but not merge with the other leaf.
935
+ *
936
+ * This situation won't appear in typical usage, and its perf penalty
937
+ * will go away once you reload. Thus we tolerate it instead of figuring out
938
+ * how to delete leaves from a B+Tree.
939
+ */
940
+ function pushSaveItem(acc, item) {
941
+ if (acc.length > 0) {
942
+ const previous = acc.at(-1);
943
+ if (previous.isDeleted === item.isDeleted &&
944
+ previous.bunchId === item.bunchId &&
945
+ previous.startCounter + previous.count === item.startCounter) {
946
+ // Combine items.
947
+ // @ts-expect-error Mutating for convenience; no aliasing to worry about.
948
+ previous.count += item.count;
949
+ return;
950
+ }
951
+ }
952
+ acc.push(item);
953
+ }
954
+ /**
955
+ * Builds a tree with the given leaves. Used by IdList.load.
956
+ *
957
+ * In contrast to inserting the leaves one-by-one, this function balances the
958
+ * tree, with full inner nodes (M children) whenever possible,
959
+ * and runs in O(L) time instead of O(L log(L)).
960
+ */
961
+ function buildTree(leaves, startIndex, depthRemaining) {
962
+ if (depthRemaining === 1) {
963
+ return new InnerNodeLeaf(leaves.slice(startIndex, startIndex + M));
964
+ }
965
+ else {
966
+ const children = [];
967
+ const childLeafCount = Math.pow(M, depthRemaining - 1);
968
+ for (let i = 0; i < M; i++) {
969
+ const childStartIndex = startIndex + i * childLeafCount;
970
+ if (childStartIndex >= leaves.length)
971
+ break;
972
+ children.push(buildTree(leaves, childStartIndex, depthRemaining - 1));
973
+ }
974
+ return new InnerNodeInner(children);
975
+ }
420
976
  }
421
977
  //# sourceMappingURL=id_list.js.map