dacument 1.1.0 → 1.2.1

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 CHANGED
@@ -51,6 +51,7 @@ doc.body.insertAt(0, "H");
51
51
  doc.tags.add("draft");
52
52
  doc.items.push("milk");
53
53
 
54
+ // Wire ops to your transport.
54
55
  doc.addEventListener("change", (event) => channel.send(event.ops));
55
56
  channel.onmessage = (ops) => doc.merge(ops);
56
57
 
@@ -60,13 +61,13 @@ doc.addEventListener("merge", ({ actor, target, method, data }) => {
60
61
  ```
61
62
 
62
63
  `create()` returns `roleKeys` for owner/manager/editor; store them securely and
63
- distribute the highest role key per actor as needed.
64
+ distribute the highest role key each actor should hold.
64
65
 
65
66
  ## Schema and fields
66
67
 
67
68
  - `register` fields behave like normal properties: `doc.title = "hi"`.
68
69
  - Other fields return safe CRDT views: `doc.items.push("x")`.
69
- - Unknown fields and schema bypasses throw.
70
+ - Unknown fields or schema bypasses throw.
70
71
  - UI updates should listen to `merge` events.
71
72
 
72
73
  Supported CRDT field types:
@@ -78,7 +79,9 @@ Supported CRDT field types:
78
79
  - `map` - OR-Map.
79
80
  - `record` - OR-Record.
80
81
 
81
- Map keys must be JSON-compatible values (`string`, `number`, `boolean`, `null`, arrays, or objects). For string-keyed data, prefer `record`.
82
+ Map keys must be JSON-compatible values (`string`, `number`, `boolean`, `null`,
83
+ arrays, or objects). For string-keyed data, prefer `record`. For non-JSON or
84
+ identity-based keys, use `CRMap` with a stable `key` function.
82
85
 
83
86
  ## Roles and ACL
84
87
 
@@ -101,14 +104,17 @@ await doc.flush();
101
104
 
102
105
  Before any schema/load/create, call `await Dacument.setActorInfo(...)` once per
103
106
  process. The actor id must be a 256-bit base64url string (e.g.
104
- `bytecodec` libarys `generateNonce()`), and the actor key pair must be ES256 (P-256).
105
- Subsequent calls are ignored. On first merge, Dacument auto-attaches the
106
- actor's `publicKeyJwk` to its own ACL entry (if missing).
107
-
108
- Each actor signs with the role key they were given (owner/manager/editor). Load
109
- with the highest role key you have; viewers load without a key.
110
- Role keys are generated once at `create()`; public keys are embedded in the
111
- snapshot and never rotated.
107
+ `bytecodec` library's `generateNonce()`), and the actor key pair must be ES256 (P-256).
108
+ Updating actor info requires providing the current keys. On first merge, Dacument
109
+ auto-attaches the actor's `publicKeyJwk` to its own ACL entry (if missing) and
110
+ pins it for actor-signature verification. To rotate actor keys in-process, call
111
+ `Dacument.setActorInfo` again with the new keys plus
112
+ `currentPrivateKeyJwk`/`currentPublicKeyJwk`.
113
+
114
+ Write ops are role-signed; acks are actor-signed with the per-actor key set via
115
+ `setActorInfo`. Load with the highest role key you have; viewers load without a
116
+ key. Role keys are generated once at `create()`; role public keys are embedded
117
+ in the snapshot and never rotated.
112
118
 
113
119
  ## Networking and sync
114
120
 
@@ -144,20 +150,44 @@ const bob = await Dacument.load({
144
150
  });
145
151
  ```
146
152
 
147
- Snapshots do not include schema or schema ids; callers must supply the schema on load.
153
+ Snapshots do not include the schema or schema ids; callers must supply the schema on load.
148
154
 
149
155
  ## Events and values
150
156
 
151
- - `doc.addEventListener("change", handler)` emits ops for network sync (writer ops are signed; acks are unsigned).
157
+ - `doc.addEventListener("change", handler)` emits ops for network sync
158
+ (writer ops are role-signed; acks are actor-signed by non-revoked actors and
159
+ verified against ACL-pinned actor public keys).
152
160
  - `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
153
161
  - `doc.addEventListener("error", handler)` emits signing/verification errors.
154
162
  - `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
163
+ - `doc.addEventListener("reset", handler)` emits `{ oldDocId, newDocId, ts, by, reason }`.
155
164
  - `doc.selfRevoke()` emits a signed ACL op that revokes the current actor.
165
+ - `await doc.accessReset({ reason })` creates a new Dacument with fresh keys and emits a reset op.
166
+ - `doc.getResetState()` returns reset metadata (or `null`).
156
167
  - `await doc.flush()` waits for pending signatures so all local ops are emitted.
157
168
  - `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
158
169
  - `await doc.verifyActorIntegrity(...)` verifies per-actor signatures on demand.
159
170
  - Revoked actors cannot snapshot; reads are masked to initial values.
160
171
 
172
+ ## Access reset (key compromise response)
173
+
174
+ If an owner suspects role key compromise, call `accessReset()` to fork to a new
175
+ docId and revoke the old one:
176
+
177
+ ```ts
178
+ const { newDoc, oldDocOps, newDocSnapshot, roleKeys } =
179
+ await doc.accessReset({ reason: "suspected compromise" });
180
+ ```
181
+
182
+ `accessReset()` materializes the current state into a new Dacument with fresh
183
+ role keys, emits a signed `reset` op for the old doc, and returns the new
184
+ snapshot + keys. The reset is stored as a CRDT op so all replicas converge. Once
185
+ reset, the old doc rejects any ops after the reset stamp and throws on writes:
186
+ `Dacument is reset/deprecated. Use newDocId: <id>`. Snapshots and verification
187
+ still work so you can archive/inspect history.
188
+ If an attacker already has the owner key, they can also reset; this is a
189
+ response tool for suspected compromise, not a prevention mechanism.
190
+
161
191
  ## Actor identity (cold path)
162
192
 
163
193
  Every op may include an `actorSig` (detached ES256 signature over the op token).
@@ -170,17 +200,17 @@ from the ACL at the op stamp and returns a summary plus failures.
170
200
 
171
201
  Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
172
202
  actors (including viewers) have acknowledged a given HLC. Acks are emitted
173
- automatically after merges that apply new non-ack ops. Acks are unsigned
174
- (`alg: "none"`); signed acks are rejected.
203
+ automatically after merges that apply new non-ack ops. Acks are ES256 actor-signed
204
+ by non-revoked actors and verified against ACL-pinned actor public keys.
175
205
  If any non-revoked actor is offline and never acks, tombstones are kept.
176
206
 
177
207
  ## Guarantees
178
208
 
179
209
  - Schema enforcement is strict; unknown fields are rejected.
180
- - Ops are accepted only if the CRDT patch is valid and the signature verifies
181
- (acks are unsigned and signed acks are rejected).
210
+ - Ops are accepted only if the CRDT patch is valid and the role signature
211
+ verifies; acks require a valid actor signature.
182
212
  - Role checks are applied at the op stamp time (HLC).
183
- - IDs are base64url nonces from `bytecodec` librarys `generateNonce()` (32 random bytes).
213
+ - IDs are base64url nonces from `bytecodec` library's `generateNonce()` (32 random bytes).
184
214
  - Private keys are returned by `create()` and never stored by Dacument.
185
215
  - Snapshots may include ops that are rejected; invalid ops are ignored on load.
186
216
 
@@ -189,8 +219,9 @@ replicas. Dacument does not provide transport; use `change` events to wire it up
189
219
 
190
220
  ## Possible threats and how to handle
191
221
 
192
- - Key compromise: no rotation exists, so migrate by snapshotting into a new
193
- dacument with fresh keys.
222
+ - Role key compromise: role keys cannot be rotated; use `accessReset` or
223
+ snapshot into a new Dacument with fresh keys. Actor keys can be rotated by
224
+ providing the current keys.
194
225
  - Shared role keys: attribution is role-level, not per-user; treat roles as
195
226
  trust groups and log merge events if you need auditing.
196
227
  - Insider DoS/flooding: rate-limit ops, cap payload sizes, and monitor merge
@@ -2,6 +2,8 @@ import { DAGNode } from "../DAGNode/class.js";
2
2
  export declare class CRArray<T> {
3
3
  private readonly nodes;
4
4
  private readonly nodeById;
5
+ private aliveCount;
6
+ private lastAliveIndex;
5
7
  private readonly listeners;
6
8
  constructor(snapshot?: readonly DAGNode<T>[]);
7
9
  get length(): number;
@@ -29,6 +31,7 @@ export declare class CRArray<T> {
29
31
  sort(compareFn?: (a: DAGNode<T>, b: DAGNode<T>) => number): this;
30
32
  private alive;
31
33
  private lastAliveId;
34
+ private recomputeLastAliveIndex;
32
35
  private afterIdForAliveInsertAt;
33
36
  private emit;
34
37
  }
@@ -1,11 +1,29 @@
1
1
  import { DAGNode } from "../DAGNode/class.js";
2
2
  const ROOT = [];
3
3
  function afterKey(after) {
4
- return after.join(",");
4
+ return after.length < 2 ? (after[0] ?? "") : after.join(",");
5
+ }
6
+ function isIndexKey(value) {
7
+ const length = value.length;
8
+ if (length === 0)
9
+ return false;
10
+ const first = value.charCodeAt(0);
11
+ if (first < 48 || first > 57)
12
+ return false;
13
+ if (length > 1 && first === 48)
14
+ return false;
15
+ for (let i = 1; i < length; i++) {
16
+ const code = value.charCodeAt(i);
17
+ if (code < 48 || code > 57)
18
+ return false;
19
+ }
20
+ return true;
5
21
  }
6
22
  export class CRArray {
7
23
  nodes = [];
8
24
  nodeById = new Map();
25
+ aliveCount = 0;
26
+ lastAliveIndex = -1;
9
27
  listeners = new Set();
10
28
  constructor(snapshot) {
11
29
  if (snapshot) {
@@ -14,6 +32,8 @@ export class CRArray {
14
32
  continue;
15
33
  this.nodes.push(node);
16
34
  this.nodeById.set(node.id, node);
35
+ if (!node.deleted)
36
+ this.aliveCount++;
17
37
  }
18
38
  }
19
39
  this.sort();
@@ -22,13 +42,13 @@ export class CRArray {
22
42
  if (typeof property === "string") {
23
43
  if (property === "length")
24
44
  return target.length;
25
- if (/^(0|[1-9]\d*)$/.test(property))
45
+ if (isIndexKey(property))
26
46
  return target.at(Number(property));
27
47
  }
28
48
  return Reflect.get(target, property, receiver);
29
49
  },
30
50
  set: (target, property, value, receiver) => {
31
- if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
51
+ if (typeof property === "string" && isIndexKey(property)) {
32
52
  const index = Number(property);
33
53
  target.setAt(index, value);
34
54
  return true;
@@ -36,7 +56,7 @@ export class CRArray {
36
56
  return Reflect.set(target, property, value, receiver);
37
57
  },
38
58
  has: (target, property) => {
39
- if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
59
+ if (typeof property === "string" && isIndexKey(property)) {
40
60
  return Number(property) < target.length;
41
61
  }
42
62
  return Reflect.has(target, property);
@@ -49,7 +69,7 @@ export class CRArray {
49
69
  return keys;
50
70
  },
51
71
  getOwnPropertyDescriptor: (target, property) => {
52
- if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
72
+ if (typeof property === "string" && isIndexKey(property)) {
53
73
  if (Number(property) >= target.length)
54
74
  return undefined;
55
75
  return {
@@ -64,11 +84,7 @@ export class CRArray {
64
84
  });
65
85
  }
66
86
  get length() {
67
- let count = 0;
68
- for (const node of this.nodes)
69
- if (!node.deleted)
70
- count++;
71
- return count;
87
+ return this.aliveCount;
72
88
  }
73
89
  // --- public API ---
74
90
  onChange(listener) {
@@ -79,9 +95,8 @@ export class CRArray {
79
95
  return this.nodes.slice();
80
96
  }
81
97
  push(...items) {
82
- let after = this.lastAliveId()
83
- ? [this.lastAliveId()]
84
- : ROOT;
98
+ const lastAliveId = this.lastAliveId();
99
+ let after = lastAliveId ? [lastAliveId] : ROOT;
85
100
  const changed = [];
86
101
  for (const item of items) {
87
102
  const node = new DAGNode({ value: item, after });
@@ -89,6 +104,7 @@ export class CRArray {
89
104
  this.nodeById.set(node.id, node);
90
105
  changed.push(node);
91
106
  after = [node.id];
107
+ this.aliveCount++;
92
108
  }
93
109
  this.sort();
94
110
  this.emit(changed);
@@ -103,19 +119,26 @@ export class CRArray {
103
119
  this.nodeById.set(node.id, node);
104
120
  changed.push(node);
105
121
  after = [node.id];
122
+ this.aliveCount++;
106
123
  }
107
124
  this.sort();
108
125
  this.emit(changed);
109
126
  return this.length;
110
127
  }
111
128
  pop() {
112
- for (let index = this.nodes.length - 1; index >= 0; index--) {
129
+ for (let index = this.lastAliveIndex; index >= 0; index--) {
113
130
  const node = this.nodes[index];
114
- if (!node.deleted) {
115
- node.deleted = true;
116
- this.emit([node]);
117
- return node.value;
131
+ if (node.deleted)
132
+ continue;
133
+ node.deleted = true;
134
+ this.aliveCount--;
135
+ this.lastAliveIndex = index - 1;
136
+ while (this.lastAliveIndex >= 0 &&
137
+ this.nodes[this.lastAliveIndex].deleted) {
138
+ this.lastAliveIndex--;
118
139
  }
140
+ this.emit([node]);
141
+ return node.value;
119
142
  }
120
143
  return undefined;
121
144
  }
@@ -123,6 +146,9 @@ export class CRArray {
123
146
  for (const node of this.nodes) {
124
147
  if (!node.deleted) {
125
148
  node.deleted = true;
149
+ this.aliveCount--;
150
+ if (this.aliveCount === 0)
151
+ this.lastAliveIndex = -1;
126
152
  this.emit([node]);
127
153
  return node.value;
128
154
  }
@@ -130,7 +156,23 @@ export class CRArray {
130
156
  return undefined;
131
157
  }
132
158
  at(index) {
133
- return this.alive().at(index);
159
+ const length = this.aliveCount;
160
+ let target = Math.trunc(Number(index));
161
+ if (Number.isNaN(target))
162
+ target = 0;
163
+ if (target < 0)
164
+ target = length + target;
165
+ if (target < 0 || target >= length)
166
+ return undefined;
167
+ let aliveIndex = 0;
168
+ for (const node of this.nodes) {
169
+ if (node.deleted)
170
+ continue;
171
+ if (aliveIndex === target)
172
+ return node.value;
173
+ aliveIndex++;
174
+ }
175
+ return undefined;
134
176
  }
135
177
  setAt(index, value) {
136
178
  if (!Number.isInteger(index))
@@ -144,6 +186,7 @@ export class CRArray {
144
186
  continue;
145
187
  if (aliveIndex === index) {
146
188
  node.deleted = true;
189
+ this.aliveCount--;
147
190
  deletedNode = node;
148
191
  break;
149
192
  }
@@ -155,19 +198,70 @@ export class CRArray {
155
198
  const newNode = new DAGNode({ value, after });
156
199
  this.nodes.push(newNode);
157
200
  this.nodeById.set(newNode.id, newNode);
201
+ this.aliveCount++;
158
202
  this.sort();
159
203
  const changed = deletedNode ? [deletedNode, newNode] : [newNode];
160
204
  this.emit(changed);
161
205
  return this;
162
206
  }
163
207
  slice(start, end) {
164
- return this.alive().slice(start, end);
208
+ const length = this.aliveCount;
209
+ let from = start === undefined ? 0 : Math.trunc(Number(start));
210
+ if (Number.isNaN(from))
211
+ from = 0;
212
+ if (from < 0)
213
+ from = Math.max(length + from, 0);
214
+ else if (from > length)
215
+ from = length;
216
+ let to = end === undefined ? length : Math.trunc(Number(end));
217
+ if (Number.isNaN(to))
218
+ to = 0;
219
+ if (to < 0)
220
+ to = Math.max(length + to, 0);
221
+ else if (to > length)
222
+ to = length;
223
+ if (to <= from)
224
+ return [];
225
+ const resultLength = to - from;
226
+ const result = new Array(resultLength);
227
+ let aliveIndex = 0;
228
+ let resultIndex = 0;
229
+ for (const node of this.nodes) {
230
+ if (node.deleted)
231
+ continue;
232
+ if (aliveIndex >= to)
233
+ break;
234
+ if (aliveIndex >= from)
235
+ result[resultIndex++] = node.value;
236
+ aliveIndex++;
237
+ }
238
+ if (resultIndex !== resultLength)
239
+ result.length = resultIndex;
240
+ return result;
165
241
  }
166
242
  includes(value) {
167
- return this.alive().includes(value);
243
+ const valueIsNaN = value !== value;
244
+ for (const node of this.nodes) {
245
+ if (node.deleted)
246
+ continue;
247
+ const nodeValue = node.value;
248
+ if (nodeValue === value)
249
+ return true;
250
+ if (valueIsNaN && nodeValue !== nodeValue)
251
+ return true;
252
+ }
253
+ return false;
168
254
  }
169
255
  indexOf(value) {
170
- return this.alive().indexOf(value);
256
+ let aliveIndex = 0;
257
+ for (const node of this.nodes) {
258
+ if (node.deleted)
259
+ continue;
260
+ if (node.value === value)
261
+ return aliveIndex;
262
+ aliveIndex++;
263
+ }
264
+ return -1;
171
265
  }
172
266
  find(predicate, thisArg) {
173
267
  return this.alive().find(predicate, thisArg);
@@ -207,10 +301,13 @@ export class CRArray {
207
301
  const clone = structuredClone(remote);
208
302
  this.nodes.push(clone);
209
303
  this.nodeById.set(clone.id, clone);
304
+ if (!clone.deleted)
305
+ this.aliveCount++;
210
306
  changed.push(clone);
211
307
  }
212
308
  else if (!local.deleted && remote.deleted) {
213
309
  local.deleted = true;
310
+ this.aliveCount--;
214
311
  changed.push(local);
215
312
  }
216
313
  }
@@ -223,6 +320,7 @@ export class CRArray {
223
320
  sort(compareFn) {
224
321
  if (compareFn) {
225
322
  this.nodes.sort(compareFn);
323
+ this.recomputeLastAliveIndex();
226
324
  return this;
227
325
  }
228
326
  this.nodes.sort((left, right) => {
@@ -240,23 +338,42 @@ export class CRArray {
240
338
  return left.id > right.id ? -1 : 1;
241
339
  return left.id < right.id ? -1 : 1;
242
340
  });
341
+ this.recomputeLastAliveIndex();
243
342
  return this;
244
343
  }
245
344
  // --- internals ---
246
345
  alive() {
247
- const values = [];
248
- for (const node of this.nodes)
249
- if (!node.deleted)
250
- values.push(node.value);
346
+ const values = new Array(this.aliveCount);
347
+ let aliveIndex = 0;
348
+ for (const node of this.nodes) {
349
+ if (node.deleted)
350
+ continue;
351
+ values[aliveIndex++] = node.value;
352
+ }
353
+ if (aliveIndex !== values.length)
354
+ values.length = aliveIndex;
251
355
  return values;
252
356
  }
253
357
  lastAliveId() {
358
+ if (this.lastAliveIndex < 0)
359
+ return null;
360
+ const node = this.nodes[this.lastAliveIndex];
361
+ if (!node || node.deleted) {
362
+ this.recomputeLastAliveIndex();
363
+ if (this.lastAliveIndex < 0)
364
+ return null;
365
+ return this.nodes[this.lastAliveIndex].id;
366
+ }
367
+ return node.id;
368
+ }
369
+ recomputeLastAliveIndex() {
254
370
  for (let index = this.nodes.length - 1; index >= 0; index--) {
255
- const node = this.nodes[index];
256
- if (!node.deleted)
257
- return node.id;
371
+ if (!this.nodes[index].deleted) {
372
+ this.lastAliveIndex = index;
373
+ return;
374
+ }
258
375
  }
259
- return null;
376
+ this.lastAliveIndex = -1;
260
377
  }
261
378
  afterIdForAliveInsertAt(index) {
262
379
  if (index === 0)
@@ -2,6 +2,8 @@ import { DAGNode } from "../DAGNode/class.js";
2
2
  export declare class CRText<CharT extends string = string> {
3
3
  private readonly nodes;
4
4
  private readonly nodeById;
5
+ private aliveCount;
6
+ private lastAliveIndex;
5
7
  private readonly listeners;
6
8
  constructor(snapshot?: readonly DAGNode<CharT>[]);
7
9
  get length(): number;
@@ -13,7 +15,8 @@ export declare class CRText<CharT extends string = string> {
13
15
  deleteAt(index: number): CharT | undefined;
14
16
  merge(remoteSnapshot: DAGNode<CharT>[] | DAGNode<CharT>): DAGNode<CharT>[];
15
17
  sort(compareFn?: (a: DAGNode<CharT>, b: DAGNode<CharT>) => number): this;
16
- private alive;
17
18
  private afterIdForAliveInsertAt;
19
+ private lastAliveId;
20
+ private recomputeLastAliveIndex;
18
21
  private emit;
19
22
  }
@@ -1,11 +1,13 @@
1
1
  import { DAGNode } from "../DAGNode/class.js";
2
2
  const ROOT = [];
3
3
  function afterKey(after) {
4
- return after.join(",");
4
+ return after.length < 2 ? (after[0] ?? "") : after.join(",");
5
5
  }
6
6
  export class CRText {
7
7
  nodes = [];
8
8
  nodeById = new Map();
9
+ aliveCount = 0;
10
+ lastAliveIndex = -1;
9
11
  listeners = new Set();
10
12
  constructor(snapshot) {
11
13
  if (snapshot) {
@@ -14,16 +16,14 @@ export class CRText {
14
16
  continue;
15
17
  this.nodes.push(node);
16
18
  this.nodeById.set(node.id, node);
19
+ if (!node.deleted)
20
+ this.aliveCount++;
17
21
  }
18
22
  }
19
23
  this.sort();
20
24
  }
21
25
  get length() {
22
- let count = 0;
23
- for (const node of this.nodes)
24
- if (!node.deleted)
25
- count++;
26
- return count;
26
+ return this.aliveCount;
27
27
  }
28
28
  // --- public API ---
29
29
  onChange(listener) {
@@ -41,19 +41,41 @@ export class CRText {
41
41
  return output;
42
42
  }
43
43
  at(index) {
44
- return this.alive().at(index);
44
+ let target = Math.trunc(Number(index));
45
+ if (Number.isNaN(target))
46
+ target = 0;
47
+ if (target < 0)
48
+ target = this.length + target;
49
+ if (target < 0)
50
+ return undefined;
51
+ let aliveIndex = 0;
52
+ for (const node of this.nodes) {
53
+ if (node.deleted)
54
+ continue;
55
+ if (aliveIndex === target)
56
+ return node.value;
57
+ aliveIndex++;
58
+ }
59
+ return undefined;
45
60
  }
46
61
  insertAt(index, char) {
47
62
  if (!Number.isInteger(index))
48
63
  throw new TypeError("CRText.insertAt: index must be an integer");
49
64
  if (index < 0)
50
65
  throw new RangeError("CRText.insertAt: negative index not supported");
51
- if (index > this.length)
66
+ const length = this.aliveCount;
67
+ if (index > length)
52
68
  throw new RangeError("CRText.insertAt: index out of bounds");
53
- const after = this.afterIdForAliveInsertAt(index);
69
+ const lastAliveId = this.lastAliveId();
70
+ const after = index === length
71
+ ? lastAliveId
72
+ ? [lastAliveId]
73
+ : ROOT
74
+ : this.afterIdForAliveInsertAt(index);
54
75
  const node = new DAGNode({ value: char, after });
55
76
  this.nodes.push(node);
56
77
  this.nodeById.set(node.id, node);
78
+ this.aliveCount++;
57
79
  this.sort();
58
80
  this.emit([node]);
59
81
  return this;
@@ -64,11 +86,23 @@ export class CRText {
64
86
  if (index < 0)
65
87
  throw new RangeError("CRText.deleteAt: negative index not supported");
66
88
  let aliveIndex = 0;
67
- for (const node of this.nodes) {
89
+ for (let idx = 0; idx < this.nodes.length; idx++) {
90
+ const node = this.nodes[idx];
68
91
  if (node.deleted)
69
92
  continue;
70
93
  if (aliveIndex === index) {
71
94
  node.deleted = true;
95
+ this.aliveCount--;
96
+ if (this.aliveCount === 0) {
97
+ this.lastAliveIndex = -1;
98
+ }
99
+ else if (idx === this.lastAliveIndex) {
100
+ this.lastAliveIndex = idx - 1;
101
+ while (this.lastAliveIndex >= 0 &&
102
+ this.nodes[this.lastAliveIndex].deleted) {
103
+ this.lastAliveIndex--;
104
+ }
105
+ }
72
106
  this.emit([node]);
73
107
  return node.value;
74
108
  }
@@ -87,10 +121,13 @@ export class CRText {
87
121
  const clone = structuredClone(remote);
88
122
  this.nodes.push(clone);
89
123
  this.nodeById.set(clone.id, clone);
124
+ if (!clone.deleted)
125
+ this.aliveCount++;
90
126
  changed.push(clone);
91
127
  }
92
128
  else if (!local.deleted && remote.deleted) {
93
129
  local.deleted = true;
130
+ this.aliveCount--;
94
131
  changed.push(local);
95
132
  }
96
133
  }
@@ -103,6 +140,7 @@ export class CRText {
103
140
  sort(compareFn) {
104
141
  if (compareFn) {
105
142
  this.nodes.sort(compareFn);
143
+ this.recomputeLastAliveIndex();
106
144
  return this;
107
145
  }
108
146
  this.nodes.sort((left, right) => {
@@ -120,16 +158,10 @@ export class CRText {
120
158
  return left.id > right.id ? -1 : 1;
121
159
  return left.id < right.id ? -1 : 1;
122
160
  });
161
+ this.recomputeLastAliveIndex();
123
162
  return this;
124
163
  }
125
164
  // --- internals ---
126
- alive() {
127
- const values = [];
128
- for (const node of this.nodes)
129
- if (!node.deleted)
130
- values.push(node.value);
131
- return values;
132
- }
133
165
  afterIdForAliveInsertAt(index) {
134
166
  if (index === 0)
135
167
  return ROOT;
@@ -147,6 +179,27 @@ export class CRText {
147
179
  return [previousAliveId];
148
180
  return ROOT;
149
181
  }
182
+ lastAliveId() {
183
+ if (this.lastAliveIndex < 0)
184
+ return null;
185
+ const node = this.nodes[this.lastAliveIndex];
186
+ if (!node || node.deleted) {
187
+ this.recomputeLastAliveIndex();
188
+ if (this.lastAliveIndex < 0)
189
+ return null;
190
+ return this.nodes[this.lastAliveIndex].id;
191
+ }
192
+ return node.id;
193
+ }
194
+ recomputeLastAliveIndex() {
195
+ for (let index = this.nodes.length - 1; index >= 0; index--) {
196
+ if (!this.nodes[index].deleted) {
197
+ this.lastAliveIndex = index;
198
+ return;
199
+ }
200
+ }
201
+ this.lastAliveIndex = -1;
202
+ }
150
203
  emit(nodes) {
151
204
  if (nodes.length === 0)
152
205
  return;