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 +51 -20
- package/dist/CRArray/class.d.ts +3 -0
- package/dist/CRArray/class.js +147 -30
- package/dist/CRText/class.d.ts +4 -1
- package/dist/CRText/class.js +70 -17
- package/dist/Dacument/class.d.ts +30 -3
- package/dist/Dacument/class.js +362 -92
- package/dist/Dacument/types.d.ts +24 -1
- package/dist/Dacument/types.js +3 -1
- package/package.json +3 -3
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
|
|
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
|
|
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`,
|
|
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`
|
|
105
|
-
|
|
106
|
-
actor's `publicKeyJwk` to its own ACL entry (if missing)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
174
|
-
|
|
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
|
|
181
|
-
|
|
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`
|
|
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
|
-
-
|
|
193
|
-
|
|
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
|
package/dist/CRArray/class.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/CRArray/class.js
CHANGED
|
@@ -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 (
|
|
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" &&
|
|
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" &&
|
|
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" &&
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
129
|
+
for (let index = this.lastAliveIndex; index >= 0; index--) {
|
|
113
130
|
const node = this.nodes[index];
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
371
|
+
if (!this.nodes[index].deleted) {
|
|
372
|
+
this.lastAliveIndex = index;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
258
375
|
}
|
|
259
|
-
|
|
376
|
+
this.lastAliveIndex = -1;
|
|
260
377
|
}
|
|
261
378
|
afterIdForAliveInsertAt(index) {
|
|
262
379
|
if (index === 0)
|
package/dist/CRText/class.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/CRText/class.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
const length = this.aliveCount;
|
|
67
|
+
if (index > length)
|
|
52
68
|
throw new RangeError("CRText.insertAt: index out of bounds");
|
|
53
|
-
const
|
|
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 (
|
|
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;
|