dacument 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/CRArray/class.d.ts +34 -0
- package/dist/CRArray/class.js +284 -0
- package/dist/CRMap/class.d.ts +55 -0
- package/dist/CRMap/class.js +222 -0
- package/dist/CRRecord/class.d.ts +34 -0
- package/dist/CRRecord/class.js +154 -0
- package/dist/CRRegister/class.d.ts +30 -0
- package/dist/CRRegister/class.js +82 -0
- package/dist/CRSet/class.d.ts +52 -0
- package/dist/CRSet/class.js +198 -0
- package/dist/CRText/class.d.ts +19 -0
- package/dist/CRText/class.js +156 -0
- package/dist/DAGNode/class.d.ts +13 -0
- package/dist/DAGNode/class.js +13 -0
- package/dist/Dacument/acl.d.ts +16 -0
- package/dist/Dacument/acl.js +84 -0
- package/dist/Dacument/class.d.ts +141 -0
- package/dist/Dacument/class.js +2015 -0
- package/dist/Dacument/clock.d.ts +16 -0
- package/dist/Dacument/clock.js +38 -0
- package/dist/Dacument/crypto.d.ts +26 -0
- package/dist/Dacument/crypto.js +64 -0
- package/dist/Dacument/types.d.ts +212 -0
- package/dist/Dacument/types.js +79 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 simple-crdts contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Dacument
|
|
2
|
+
|
|
3
|
+
Dacument is a schema-driven CRDT document that signs every operation and
|
|
4
|
+
enforces role-based ACLs at merge time. Local writes emit signed ops; state
|
|
5
|
+
advances only when ops are merged. It gives you a JS object-like API for
|
|
6
|
+
register fields and safe CRDT views for all other field types.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install dacument
|
|
12
|
+
# or
|
|
13
|
+
pnpm add dacument
|
|
14
|
+
# or
|
|
15
|
+
yarn add dacument
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { generateNonce } from "bytecodec";
|
|
22
|
+
import { Dacument } from "dacument";
|
|
23
|
+
|
|
24
|
+
const actorId = generateNonce(); // 256-bit base64url id
|
|
25
|
+
Dacument.setActorId(actorId);
|
|
26
|
+
|
|
27
|
+
const schema = Dacument.schema({
|
|
28
|
+
title: Dacument.register({ jsType: "string", regex: /^[a-z ]+$/i }),
|
|
29
|
+
body: Dacument.text(),
|
|
30
|
+
items: Dacument.array({ jsType: "string" }),
|
|
31
|
+
tags: Dacument.set({ jsType: "string" }),
|
|
32
|
+
meta: Dacument.record({ jsType: "string" }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const { docId, snapshot, roleKeys } = await Dacument.create({ schema });
|
|
36
|
+
|
|
37
|
+
const doc = await Dacument.load({
|
|
38
|
+
schema,
|
|
39
|
+
roleKey: roleKeys.owner.privateKey,
|
|
40
|
+
snapshot,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
doc.title = "Hello world";
|
|
44
|
+
doc.body.insertAt(0, "H");
|
|
45
|
+
doc.tags.add("draft");
|
|
46
|
+
doc.items.push("milk");
|
|
47
|
+
|
|
48
|
+
doc.addEventListener("change", (event) => channel.send(event.ops));
|
|
49
|
+
channel.onmessage = (ops) => doc.merge(ops);
|
|
50
|
+
|
|
51
|
+
doc.addEventListener("merge", ({ actor, target, method, data }) => {
|
|
52
|
+
// Update UI from a single merge stream.
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`create()` returns `roleKeys` for owner/manager/editor; store them securely and
|
|
57
|
+
distribute the highest role key per actor as needed.
|
|
58
|
+
|
|
59
|
+
## Schema and fields
|
|
60
|
+
|
|
61
|
+
- `register` fields behave like normal properties: `doc.title = "hi"`.
|
|
62
|
+
- Other fields return safe CRDT views: `doc.items.push("x")`.
|
|
63
|
+
- Unknown fields and schema bypasses throw.
|
|
64
|
+
- UI updates should listen to `merge` events.
|
|
65
|
+
|
|
66
|
+
Supported CRDT field types:
|
|
67
|
+
|
|
68
|
+
- `register` - last writer wins register.
|
|
69
|
+
- `text` - text RGA.
|
|
70
|
+
- `array` - array RGA.
|
|
71
|
+
- `set` - OR-Set.
|
|
72
|
+
- `map` - OR-Map.
|
|
73
|
+
- `record` - OR-Record.
|
|
74
|
+
|
|
75
|
+
Map keys must be JSON-compatible values (`string`, `number`, `boolean`, `null`, arrays, or objects). For string-keyed data, prefer `record`.
|
|
76
|
+
|
|
77
|
+
## Roles and ACL
|
|
78
|
+
|
|
79
|
+
Roles are evaluated at the op stamp time (HLC).
|
|
80
|
+
|
|
81
|
+
- Owner: full control (including ownership transfer).
|
|
82
|
+
- Manager: can grant editor/viewer/revoked roles.
|
|
83
|
+
- Editor: can write non-ACL fields.
|
|
84
|
+
- Viewer: read-only.
|
|
85
|
+
- Revoked: reads are masked to initial values; writes are rejected.
|
|
86
|
+
|
|
87
|
+
Grant roles via `doc.acl` (viewer/revoked have no key):
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const bobId = generateNonce();
|
|
91
|
+
doc.acl.setRole(bobId, "editor");
|
|
92
|
+
doc.acl.setRole("user-viewer", "viewer");
|
|
93
|
+
await doc.flush();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Before any schema/load/create, call `Dacument.setActorId()` once per process.
|
|
97
|
+
The actor id must be a 256-bit base64url string (e.g. `bytecodec.generateNonce()`).
|
|
98
|
+
Subsequent calls are ignored.
|
|
99
|
+
|
|
100
|
+
Each actor signs with the role key they were given (owner/manager/editor). Load
|
|
101
|
+
with the highest role key you have; viewers load without a key.
|
|
102
|
+
Role keys are generated once at `create()`; public keys are embedded in the
|
|
103
|
+
snapshot and never rotated.
|
|
104
|
+
|
|
105
|
+
## Networking and sync
|
|
106
|
+
|
|
107
|
+
Use `change` events to relay signed ops, and `merge` to apply them:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
doc.addEventListener("change", (event) => send(event.ops));
|
|
111
|
+
|
|
112
|
+
// on remote
|
|
113
|
+
await peer.merge(ops);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Local writes do not update state until merged. If you want a single UI update
|
|
117
|
+
path, broadcast ops (even back to yourself) and drive UI from `merge` events.
|
|
118
|
+
|
|
119
|
+
`merge` events mirror the confirmed operation parameters (e.g. `insertAt`,
|
|
120
|
+
`deleteAt`, `push`, `pop`, `set`, `add`) so UIs can apply minimal updates
|
|
121
|
+
without snapshotting.
|
|
122
|
+
|
|
123
|
+
To add a new replica, share a snapshot and load it:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
Dacument.setActorId(bobId);
|
|
127
|
+
const bob = await Dacument.load({
|
|
128
|
+
schema,
|
|
129
|
+
roleKey: bobKey.privateKey,
|
|
130
|
+
snapshot,
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Snapshots do not include schema or schema ids; callers must supply the schema on load.
|
|
135
|
+
|
|
136
|
+
## Events and values
|
|
137
|
+
|
|
138
|
+
- `doc.addEventListener("change", handler)` emits ops for network sync (writer ops are signed; acks are unsigned).
|
|
139
|
+
- `doc.addEventListener("merge", handler)` emits `{ actor, target, method, data }`.
|
|
140
|
+
- `doc.addEventListener("error", handler)` emits signing/verification errors.
|
|
141
|
+
- `doc.addEventListener("revoked", handler)` fires when the current actor is revoked.
|
|
142
|
+
- `await doc.flush()` waits for pending signatures so all local ops are emitted.
|
|
143
|
+
- `doc.snapshot()` returns a loadable op log (`{ docId, roleKeys, ops }`).
|
|
144
|
+
- Revoked actors cannot snapshot; reads are masked to initial values.
|
|
145
|
+
|
|
146
|
+
## Garbage collection
|
|
147
|
+
|
|
148
|
+
Dacument tracks per-actor `ack` ops and compacts tombstones once all non-revoked
|
|
149
|
+
actors (including viewers) have acknowledged a given HLC. Acks are emitted
|
|
150
|
+
automatically after merges that apply new non-ack ops. Acks are unsigned
|
|
151
|
+
(`alg: "none"`); signed acks are rejected.
|
|
152
|
+
If any non-revoked actor is offline and never acks, tombstones are kept.
|
|
153
|
+
|
|
154
|
+
## Guarantees
|
|
155
|
+
|
|
156
|
+
- Schema enforcement is strict; unknown fields are rejected.
|
|
157
|
+
- Ops are accepted only if the CRDT patch is valid and the signature verifies
|
|
158
|
+
(acks are unsigned and signed acks are rejected).
|
|
159
|
+
- Role checks are applied at the op stamp time (HLC).
|
|
160
|
+
- IDs are base64url nonces from `bytecodec` librarys `generateNonce()` (32 random bytes).
|
|
161
|
+
- Private keys are returned by `create()` and never stored by Dacument.
|
|
162
|
+
- Snapshots may include ops that are rejected; invalid ops are ignored on load.
|
|
163
|
+
|
|
164
|
+
Eventual consistency is achieved when all signed ops are delivered to all
|
|
165
|
+
replicas. Dacument does not provide transport; use `change` events to wire it up.
|
|
166
|
+
|
|
167
|
+
## Compatibility
|
|
168
|
+
|
|
169
|
+
- ESM only (`type: module`).
|
|
170
|
+
- Requires WebCrypto (`node >= 18` or modern browsers).
|
|
171
|
+
|
|
172
|
+
## Scripts
|
|
173
|
+
|
|
174
|
+
- `npm test` runs the test suite (build included).
|
|
175
|
+
- `npm run bench` runs all CRDT micro-benchmarks (build included).
|
|
176
|
+
- `npm run sim` runs a worker-thread stress simulation.
|
|
177
|
+
- `npm run verify` runs tests, benchmarks, and the simulation in one go.
|
|
178
|
+
|
|
179
|
+
## Benchmarks
|
|
180
|
+
|
|
181
|
+
`npm run bench` prints CRDT timings alongside native structure baselines
|
|
182
|
+
(Array/Set/Map/string). Use environment variables like `RUNS`, `SIZE`, `READS`,
|
|
183
|
+
`WRITES`, and `MERGE_SIZE` to tune scale. Compare the CRDT lines to the native
|
|
184
|
+
lines in the output to estimate overhead on your machine. CRDT ops retain
|
|
185
|
+
causal metadata and tombstones, so write-heavy paths will be slower than native
|
|
186
|
+
structures; the baselines show the relative cost.
|
|
187
|
+
|
|
188
|
+
## Advanced exports
|
|
189
|
+
|
|
190
|
+
`CRArray`, `CRMap`, `CRRecord`, `CRRegister`, `CRSet`, and `CRText` are exported
|
|
191
|
+
from the package for building custom CRDT workflows.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DAGNode } from "../DAGNode/class.js";
|
|
2
|
+
export declare class CRArray<T> {
|
|
3
|
+
private readonly nodes;
|
|
4
|
+
private readonly nodeById;
|
|
5
|
+
private readonly listeners;
|
|
6
|
+
constructor(snapshot?: readonly DAGNode<T>[]);
|
|
7
|
+
get length(): number;
|
|
8
|
+
onChange(listener: (nodes: readonly DAGNode<T>[]) => void): () => void;
|
|
9
|
+
snapshot(): DAGNode<T>[];
|
|
10
|
+
push(...items: T[]): number;
|
|
11
|
+
unshift(...items: T[]): number;
|
|
12
|
+
pop(): T | undefined;
|
|
13
|
+
shift(): T | undefined;
|
|
14
|
+
at(index: number): T | undefined;
|
|
15
|
+
setAt(index: number, value: T): this;
|
|
16
|
+
slice(start?: number, end?: number): T[];
|
|
17
|
+
includes(value: T): boolean;
|
|
18
|
+
indexOf(value: T): number;
|
|
19
|
+
find(predicate: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): T | undefined;
|
|
20
|
+
findIndex(predicate: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): number;
|
|
21
|
+
forEach(callback: (value: T, index: number, array: T[]) => void, thisArg?: unknown): void;
|
|
22
|
+
map<U>(callback: (value: T, index: number, array: T[]) => U, thisArg?: unknown): U[];
|
|
23
|
+
filter(predicate: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): T[];
|
|
24
|
+
reduce<U>(reducer: (prev: U, curr: T, index: number, array: T[]) => U, initialValue: U): U;
|
|
25
|
+
every(predicate: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): boolean;
|
|
26
|
+
some(predicate: (value: T, index: number, array: T[]) => boolean, thisArg?: unknown): boolean;
|
|
27
|
+
[Symbol.iterator](): Iterator<T>;
|
|
28
|
+
merge(remoteSnapshot: DAGNode<T>[] | DAGNode<T>): DAGNode<T>[];
|
|
29
|
+
sort(compareFn?: (a: DAGNode<T>, b: DAGNode<T>) => number): this;
|
|
30
|
+
private alive;
|
|
31
|
+
private lastAliveId;
|
|
32
|
+
private afterIdForAliveInsertAt;
|
|
33
|
+
private emit;
|
|
34
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { DAGNode } from "../DAGNode/class.js";
|
|
2
|
+
const ROOT = [];
|
|
3
|
+
function afterKey(after) {
|
|
4
|
+
return after.join(",");
|
|
5
|
+
}
|
|
6
|
+
export class CRArray {
|
|
7
|
+
nodes = [];
|
|
8
|
+
nodeById = new Map();
|
|
9
|
+
listeners = new Set();
|
|
10
|
+
constructor(snapshot) {
|
|
11
|
+
if (snapshot) {
|
|
12
|
+
for (const node of snapshot) {
|
|
13
|
+
if (this.nodeById.has(node.id))
|
|
14
|
+
continue;
|
|
15
|
+
this.nodes.push(node);
|
|
16
|
+
this.nodeById.set(node.id, node);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
this.sort();
|
|
20
|
+
return new Proxy(this, {
|
|
21
|
+
get: (target, property, receiver) => {
|
|
22
|
+
if (typeof property === "string") {
|
|
23
|
+
if (property === "length")
|
|
24
|
+
return target.length;
|
|
25
|
+
if (/^(0|[1-9]\d*)$/.test(property))
|
|
26
|
+
return target.at(Number(property));
|
|
27
|
+
}
|
|
28
|
+
return Reflect.get(target, property, receiver);
|
|
29
|
+
},
|
|
30
|
+
set: (target, property, value, receiver) => {
|
|
31
|
+
if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
|
|
32
|
+
const index = Number(property);
|
|
33
|
+
target.setAt(index, value);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return Reflect.set(target, property, value, receiver);
|
|
37
|
+
},
|
|
38
|
+
has: (target, property) => {
|
|
39
|
+
if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
|
|
40
|
+
return Number(property) < target.length;
|
|
41
|
+
}
|
|
42
|
+
return Reflect.has(target, property);
|
|
43
|
+
},
|
|
44
|
+
ownKeys: (target) => {
|
|
45
|
+
const keys = Reflect.ownKeys(target);
|
|
46
|
+
const aliveCount = target.length;
|
|
47
|
+
for (let index = 0; index < aliveCount; index++)
|
|
48
|
+
keys.push(String(index));
|
|
49
|
+
return keys;
|
|
50
|
+
},
|
|
51
|
+
getOwnPropertyDescriptor: (target, property) => {
|
|
52
|
+
if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) {
|
|
53
|
+
if (Number(property) >= target.length)
|
|
54
|
+
return undefined;
|
|
55
|
+
return {
|
|
56
|
+
configurable: true,
|
|
57
|
+
enumerable: true,
|
|
58
|
+
writable: true,
|
|
59
|
+
value: target.at(Number(property)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return Reflect.getOwnPropertyDescriptor(target, property);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
get length() {
|
|
67
|
+
let count = 0;
|
|
68
|
+
for (const node of this.nodes)
|
|
69
|
+
if (!node.deleted)
|
|
70
|
+
count++;
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
73
|
+
// --- public API ---
|
|
74
|
+
onChange(listener) {
|
|
75
|
+
this.listeners.add(listener);
|
|
76
|
+
return () => this.listeners.delete(listener);
|
|
77
|
+
}
|
|
78
|
+
snapshot() {
|
|
79
|
+
return this.nodes.slice();
|
|
80
|
+
}
|
|
81
|
+
push(...items) {
|
|
82
|
+
let after = this.lastAliveId()
|
|
83
|
+
? [this.lastAliveId()]
|
|
84
|
+
: ROOT;
|
|
85
|
+
const changed = [];
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
const node = new DAGNode({ value: item, after });
|
|
88
|
+
this.nodes.push(node);
|
|
89
|
+
this.nodeById.set(node.id, node);
|
|
90
|
+
changed.push(node);
|
|
91
|
+
after = [node.id];
|
|
92
|
+
}
|
|
93
|
+
this.sort();
|
|
94
|
+
this.emit(changed);
|
|
95
|
+
return this.length;
|
|
96
|
+
}
|
|
97
|
+
unshift(...items) {
|
|
98
|
+
let after = ROOT;
|
|
99
|
+
const changed = [];
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
const node = new DAGNode({ value: item, after });
|
|
102
|
+
this.nodes.push(node);
|
|
103
|
+
this.nodeById.set(node.id, node);
|
|
104
|
+
changed.push(node);
|
|
105
|
+
after = [node.id];
|
|
106
|
+
}
|
|
107
|
+
this.sort();
|
|
108
|
+
this.emit(changed);
|
|
109
|
+
return this.length;
|
|
110
|
+
}
|
|
111
|
+
pop() {
|
|
112
|
+
for (let index = this.nodes.length - 1; index >= 0; index--) {
|
|
113
|
+
const node = this.nodes[index];
|
|
114
|
+
if (!node.deleted) {
|
|
115
|
+
node.deleted = true;
|
|
116
|
+
this.emit([node]);
|
|
117
|
+
return node.value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
shift() {
|
|
123
|
+
for (const node of this.nodes) {
|
|
124
|
+
if (!node.deleted) {
|
|
125
|
+
node.deleted = true;
|
|
126
|
+
this.emit([node]);
|
|
127
|
+
return node.value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
at(index) {
|
|
133
|
+
return this.alive().at(index);
|
|
134
|
+
}
|
|
135
|
+
setAt(index, value) {
|
|
136
|
+
if (!Number.isInteger(index))
|
|
137
|
+
throw new TypeError("CRArray.setAt: index must be an integer");
|
|
138
|
+
if (index < 0)
|
|
139
|
+
throw new RangeError("CRArray.setAt: negative index not supported");
|
|
140
|
+
let aliveIndex = 0;
|
|
141
|
+
let deletedNode = null;
|
|
142
|
+
for (const node of this.nodes) {
|
|
143
|
+
if (node.deleted)
|
|
144
|
+
continue;
|
|
145
|
+
if (aliveIndex === index) {
|
|
146
|
+
node.deleted = true;
|
|
147
|
+
deletedNode = node;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
aliveIndex++;
|
|
151
|
+
}
|
|
152
|
+
if (index > aliveIndex)
|
|
153
|
+
throw new RangeError("CRArray.setAt: index out of bounds");
|
|
154
|
+
const after = this.afterIdForAliveInsertAt(index);
|
|
155
|
+
const newNode = new DAGNode({ value, after });
|
|
156
|
+
this.nodes.push(newNode);
|
|
157
|
+
this.nodeById.set(newNode.id, newNode);
|
|
158
|
+
this.sort();
|
|
159
|
+
const changed = deletedNode ? [deletedNode, newNode] : [newNode];
|
|
160
|
+
this.emit(changed);
|
|
161
|
+
return this;
|
|
162
|
+
}
|
|
163
|
+
slice(start, end) {
|
|
164
|
+
return this.alive().slice(start, end);
|
|
165
|
+
}
|
|
166
|
+
includes(value) {
|
|
167
|
+
return this.alive().includes(value);
|
|
168
|
+
}
|
|
169
|
+
indexOf(value) {
|
|
170
|
+
return this.alive().indexOf(value);
|
|
171
|
+
}
|
|
172
|
+
find(predicate, thisArg) {
|
|
173
|
+
return this.alive().find(predicate, thisArg);
|
|
174
|
+
}
|
|
175
|
+
findIndex(predicate, thisArg) {
|
|
176
|
+
return this.alive().findIndex(predicate, thisArg);
|
|
177
|
+
}
|
|
178
|
+
forEach(callback, thisArg) {
|
|
179
|
+
this.alive().forEach(callback, thisArg);
|
|
180
|
+
}
|
|
181
|
+
map(callback, thisArg) {
|
|
182
|
+
return this.alive().map(callback, thisArg);
|
|
183
|
+
}
|
|
184
|
+
filter(predicate, thisArg) {
|
|
185
|
+
return this.alive().filter(predicate, thisArg);
|
|
186
|
+
}
|
|
187
|
+
reduce(reducer, initialValue) {
|
|
188
|
+
return this.alive().reduce(reducer, initialValue);
|
|
189
|
+
}
|
|
190
|
+
every(predicate, thisArg) {
|
|
191
|
+
return this.alive().every(predicate, thisArg);
|
|
192
|
+
}
|
|
193
|
+
some(predicate, thisArg) {
|
|
194
|
+
return this.alive().some(predicate, thisArg);
|
|
195
|
+
}
|
|
196
|
+
[Symbol.iterator]() {
|
|
197
|
+
return this.alive()[Symbol.iterator]();
|
|
198
|
+
}
|
|
199
|
+
merge(remoteSnapshot) {
|
|
200
|
+
const snapshot = Array.isArray(remoteSnapshot)
|
|
201
|
+
? remoteSnapshot
|
|
202
|
+
: [remoteSnapshot];
|
|
203
|
+
const changed = [];
|
|
204
|
+
for (const remote of snapshot) {
|
|
205
|
+
const local = this.nodeById.get(remote.id);
|
|
206
|
+
if (!local) {
|
|
207
|
+
const clone = structuredClone(remote);
|
|
208
|
+
this.nodes.push(clone);
|
|
209
|
+
this.nodeById.set(clone.id, clone);
|
|
210
|
+
changed.push(clone);
|
|
211
|
+
}
|
|
212
|
+
else if (!local.deleted && remote.deleted) {
|
|
213
|
+
local.deleted = true;
|
|
214
|
+
changed.push(local);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (changed.length) {
|
|
218
|
+
this.sort();
|
|
219
|
+
this.emit(changed);
|
|
220
|
+
}
|
|
221
|
+
return changed;
|
|
222
|
+
}
|
|
223
|
+
sort(compareFn) {
|
|
224
|
+
if (compareFn) {
|
|
225
|
+
this.nodes.sort(compareFn);
|
|
226
|
+
return this;
|
|
227
|
+
}
|
|
228
|
+
this.nodes.sort((left, right) => {
|
|
229
|
+
const leftIsRoot = left.after.length === 0;
|
|
230
|
+
const rightIsRoot = right.after.length === 0;
|
|
231
|
+
if (leftIsRoot !== rightIsRoot)
|
|
232
|
+
return leftIsRoot ? -1 : 1;
|
|
233
|
+
const leftAfterKey = afterKey(left.after);
|
|
234
|
+
const rightAfterKey = afterKey(right.after);
|
|
235
|
+
if (leftAfterKey !== rightAfterKey)
|
|
236
|
+
return leftAfterKey < rightAfterKey ? -1 : 1;
|
|
237
|
+
if (left.id === right.id)
|
|
238
|
+
return 0;
|
|
239
|
+
if (leftIsRoot)
|
|
240
|
+
return left.id > right.id ? -1 : 1;
|
|
241
|
+
return left.id < right.id ? -1 : 1;
|
|
242
|
+
});
|
|
243
|
+
return this;
|
|
244
|
+
}
|
|
245
|
+
// --- internals ---
|
|
246
|
+
alive() {
|
|
247
|
+
const values = [];
|
|
248
|
+
for (const node of this.nodes)
|
|
249
|
+
if (!node.deleted)
|
|
250
|
+
values.push(node.value);
|
|
251
|
+
return values;
|
|
252
|
+
}
|
|
253
|
+
lastAliveId() {
|
|
254
|
+
for (let index = this.nodes.length - 1; index >= 0; index--) {
|
|
255
|
+
const node = this.nodes[index];
|
|
256
|
+
if (!node.deleted)
|
|
257
|
+
return node.id;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
afterIdForAliveInsertAt(index) {
|
|
262
|
+
if (index === 0)
|
|
263
|
+
return ROOT;
|
|
264
|
+
let aliveIndex = 0;
|
|
265
|
+
let previousAliveId = null;
|
|
266
|
+
for (const node of this.nodes) {
|
|
267
|
+
if (node.deleted)
|
|
268
|
+
continue;
|
|
269
|
+
if (aliveIndex === index)
|
|
270
|
+
break;
|
|
271
|
+
previousAliveId = node.id;
|
|
272
|
+
aliveIndex++;
|
|
273
|
+
}
|
|
274
|
+
if (previousAliveId)
|
|
275
|
+
return [previousAliveId];
|
|
276
|
+
return ROOT;
|
|
277
|
+
}
|
|
278
|
+
emit(nodes) {
|
|
279
|
+
if (nodes.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
for (const listener of this.listeners)
|
|
282
|
+
listener(nodes);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
type CRMapNode<K, V> = {
|
|
2
|
+
op: "set";
|
|
3
|
+
id: string;
|
|
4
|
+
key: K;
|
|
5
|
+
keyId: string;
|
|
6
|
+
value: V;
|
|
7
|
+
} | {
|
|
8
|
+
op: "del";
|
|
9
|
+
id: string;
|
|
10
|
+
keyId: string;
|
|
11
|
+
targets: string[];
|
|
12
|
+
};
|
|
13
|
+
type CRMapListener<K, V> = (patches: CRMapNode<K, V>[]) => void;
|
|
14
|
+
export declare class CRMap<K, V> implements Map<K, V> {
|
|
15
|
+
private readonly nodes;
|
|
16
|
+
private readonly seenNodeIds;
|
|
17
|
+
private readonly setTagsByKeyId;
|
|
18
|
+
private readonly tombstones;
|
|
19
|
+
private readonly aliveKeyIds;
|
|
20
|
+
private readonly latestKeyByKeyId;
|
|
21
|
+
private readonly latestValueByKeyId;
|
|
22
|
+
private readonly listeners;
|
|
23
|
+
private readonly keyIdByObjectRef;
|
|
24
|
+
private objectKeyCounter;
|
|
25
|
+
private readonly symbolKeyByRef;
|
|
26
|
+
private symbolKeyCounter;
|
|
27
|
+
private readonly keyFn;
|
|
28
|
+
constructor(options?: {
|
|
29
|
+
snapshot?: CRMapNode<K, V>[];
|
|
30
|
+
key?: (key: K) => string;
|
|
31
|
+
});
|
|
32
|
+
onChange(listener: CRMapListener<K, V>): () => void;
|
|
33
|
+
snapshot(): CRMapNode<K, V>[];
|
|
34
|
+
merge(input: CRMapNode<K, V>[] | CRMapNode<K, V>): CRMapNode<K, V>[];
|
|
35
|
+
get size(): number;
|
|
36
|
+
clear(): void;
|
|
37
|
+
delete(key: K): boolean;
|
|
38
|
+
forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: unknown): void;
|
|
39
|
+
get(key: K): V | undefined;
|
|
40
|
+
has(key: K): boolean;
|
|
41
|
+
set(key: K, value: V): this;
|
|
42
|
+
entries(): MapIterator<[K, V]>;
|
|
43
|
+
keys(): MapIterator<K>;
|
|
44
|
+
values(): MapIterator<V>;
|
|
45
|
+
[Symbol.iterator](): MapIterator<[K, V]>;
|
|
46
|
+
readonly [Symbol.toStringTag] = "CRMap";
|
|
47
|
+
private appendAndApply;
|
|
48
|
+
private applyNode;
|
|
49
|
+
private recomputeKeyId;
|
|
50
|
+
private currentSetTagsForKeyId;
|
|
51
|
+
private emit;
|
|
52
|
+
private newId;
|
|
53
|
+
private keyIdOf;
|
|
54
|
+
}
|
|
55
|
+
export {};
|