meridian-sdk 1.2.0 → 1.3.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/CHANGELOG.md +18 -0
- package/README.md +1 -0
- package/dist/agents.d.ts +143 -8
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +141 -14
- package/dist/agents.js.map +1 -1
- package/dist/client.d.ts +35 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +45 -3
- package/dist/client.js.map +1 -1
- package/dist/conflict/types.d.ts +54 -0
- package/dist/conflict/types.d.ts.map +1 -0
- package/dist/conflict/types.js +9 -0
- package/dist/conflict/types.js.map +1 -0
- package/dist/crdt/rga.d.ts +20 -4
- package/dist/crdt/rga.d.ts.map +1 -1
- package/dist/crdt/rga.js +42 -6
- package/dist/crdt/rga.js.map +1 -1
- package/dist/crdt/tree.d.ts +128 -0
- package/dist/crdt/tree.d.ts.map +1 -0
- package/dist/crdt/tree.js +304 -0
- package/dist/crdt/tree.js.map +1 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +63 -3
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +18 -1
- package/dist/schema.js.map +1 -1
- package/dist/sync/delta.d.ts +35 -0
- package/dist/sync/delta.d.ts.map +1 -1
- package/dist/sync/delta.js +4 -0
- package/dist/sync/delta.js.map +1 -1
- package/dist/undo/UndoManager.d.ts +74 -0
- package/dist/undo/UndoManager.d.ts.map +1 -0
- package/dist/undo/UndoManager.js +318 -0
- package/dist/undo/UndoManager.js.map +1 -0
- package/dist/undo/types.d.ts +63 -0
- package/dist/undo/types.d.ts.map +1 -0
- package/dist/undo/types.js +9 -0
- package/dist/undo/types.js.map +1 -0
- package/dist/utils/fractional.d.ts +78 -0
- package/dist/utils/fractional.d.ts.map +1 -0
- package/dist/utils/fractional.js +159 -0
- package/dist/utils/fractional.js.map +1 -0
- package/dist/validation/index.d.ts +107 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +123 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +1 -1
- package/src/agents.ts +224 -15
- package/src/client.ts +52 -3
- package/src/conflict/types.ts +60 -0
- package/src/crdt/rga.ts +46 -7
- package/src/crdt/tree.ts +367 -0
- package/src/index.ts +31 -1
- package/src/schema.ts +24 -1
- package/src/sync/delta.ts +30 -1
- package/src/undo/UndoManager.ts +369 -0
- package/src/undo/types.ts +74 -0
- package/src/utils/fractional.ts +166 -0
- package/src/validation/index.ts +137 -0
- package/test/conflict.test.ts +242 -0
- package/test/fractional.test.ts +127 -0
- package/test/undo.test.ts +272 -0
- package/test/validation.test.ts +137 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { RGAHandle } from "../crdt/rga.js";
|
|
2
|
+
import { TreeHandle } from "../crdt/tree.js";
|
|
3
|
+
import type {
|
|
4
|
+
UndoBatch,
|
|
5
|
+
UndoEntry,
|
|
6
|
+
RgaInsertEntry,
|
|
7
|
+
TreeAddEntry,
|
|
8
|
+
TreeDeleteEntry,
|
|
9
|
+
TreeMoveEntry,
|
|
10
|
+
TreeUpdateEntry,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
const MAX_STACK_DEPTH = 100;
|
|
14
|
+
const RGA_DEBOUNCE_MS = 250;
|
|
15
|
+
|
|
16
|
+
type SupportedHandle = RGAHandle | TreeHandle;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* CRDT-aware per-client undo/redo manager.
|
|
20
|
+
*
|
|
21
|
+
* Wraps an RGAHandle or TreeHandle. Intercept mutating methods via the manager
|
|
22
|
+
* instead of the handle directly to record undo history.
|
|
23
|
+
*
|
|
24
|
+
* Rules:
|
|
25
|
+
* - Undo sends a new valid CrdtOp (never a state rollback).
|
|
26
|
+
* - Only the local client's ops are tracked — remote ops are unaffected.
|
|
27
|
+
* - RGA Delete is NOT recorded (tombstones are final).
|
|
28
|
+
* - Stack is capped at 100 batches; oldest entries are dropped silently.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* const manager = new UndoManager(client.rga("doc"));
|
|
33
|
+
* manager.insert(0, "hello");
|
|
34
|
+
* manager.undo(); // sends 5 × RgaOp::Delete
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class UndoManager<H extends SupportedHandle> {
|
|
38
|
+
private readonly handle: H;
|
|
39
|
+
private undoStack: UndoBatch[] = [];
|
|
40
|
+
private redoStack: UndoBatch[] = [];
|
|
41
|
+
private readonly stackListeners = new Set<() => void>();
|
|
42
|
+
|
|
43
|
+
// Batch accumulation
|
|
44
|
+
private openBatch: UndoEntry[] | null = null;
|
|
45
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
46
|
+
|
|
47
|
+
constructor(handle: H) {
|
|
48
|
+
this.handle = handle;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Stack state ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
get canUndo(): boolean {
|
|
54
|
+
return this.undoStack.length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get canRedo(): boolean {
|
|
58
|
+
return this.redoStack.length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Subscribe to stack change notifications (fired after any undo/redo/record).
|
|
63
|
+
* @returns Unsubscribe function.
|
|
64
|
+
*/
|
|
65
|
+
onStackChange(listener: () => void): () => void {
|
|
66
|
+
this.stackListeners.add(listener);
|
|
67
|
+
return () => { this.stackListeners.delete(listener); };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Manual batching ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** Begin accumulating entries into a single undo step. */
|
|
73
|
+
startBatch(): void {
|
|
74
|
+
if (this.openBatch === null) {
|
|
75
|
+
this.openBatch = [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Commit the open batch as one entry on the undo stack. No-op if no batch is open. */
|
|
80
|
+
commitBatch(): void {
|
|
81
|
+
if (this.openBatch !== null && this.openBatch.length > 0) {
|
|
82
|
+
this.pushUndo({ entries: this.openBatch });
|
|
83
|
+
}
|
|
84
|
+
this.openBatch = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Discard the open batch without recording anything. */
|
|
88
|
+
cancelBatch(): void {
|
|
89
|
+
this.openBatch = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Undo / Redo ────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
undo(): void {
|
|
95
|
+
const batch = this.undoStack.pop();
|
|
96
|
+
if (batch === undefined) return;
|
|
97
|
+
const inverseEntries: UndoEntry[] = [];
|
|
98
|
+
|
|
99
|
+
// Apply inverse ops in reverse entry order.
|
|
100
|
+
for (let i = batch.entries.length - 1; i >= 0; i--) {
|
|
101
|
+
const entry = batch.entries[i]!;
|
|
102
|
+
const inverse = this.applyInverse(entry);
|
|
103
|
+
if (inverse !== null) inverseEntries.unshift(inverse);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (inverseEntries.length > 0) {
|
|
107
|
+
this.redoStack.push({ entries: inverseEntries });
|
|
108
|
+
}
|
|
109
|
+
this.notifyStackChange();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
redo(): void {
|
|
113
|
+
const batch = this.redoStack.pop();
|
|
114
|
+
if (batch === undefined) return;
|
|
115
|
+
const inverseEntries: UndoEntry[] = [];
|
|
116
|
+
|
|
117
|
+
for (let i = batch.entries.length - 1; i >= 0; i--) {
|
|
118
|
+
const entry = batch.entries[i]!;
|
|
119
|
+
const inverse = this.applyInverse(entry);
|
|
120
|
+
if (inverse !== null) inverseEntries.unshift(inverse);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (inverseEntries.length > 0) {
|
|
124
|
+
this.undoStack.push({ entries: inverseEntries });
|
|
125
|
+
if (this.undoStack.length > MAX_STACK_DEPTH) {
|
|
126
|
+
this.undoStack.shift();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.notifyStackChange();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── RGA handle proxy methods ────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Insert text at visible position. Records undo entry with HLC node IDs.
|
|
136
|
+
* Uses 250ms debounce — consecutive inserts within the window form one batch.
|
|
137
|
+
*/
|
|
138
|
+
insert(pos: number, text: string, ttlMs?: number): string[] {
|
|
139
|
+
const handle = this.asRGA();
|
|
140
|
+
const nodeIds = handle.insert(pos, text, ttlMs);
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < nodeIds.length; i++) {
|
|
143
|
+
const entry: RgaInsertEntry = {
|
|
144
|
+
kind: "rga_insert",
|
|
145
|
+
crdtId: handle.id,
|
|
146
|
+
nodeId: nodeIds[i]!,
|
|
147
|
+
pos: pos + i,
|
|
148
|
+
content: text[i]!,
|
|
149
|
+
};
|
|
150
|
+
this.recordWithDebounce(entry);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.clearRedo();
|
|
154
|
+
return nodeIds;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Delete characters by position. NOT recorded — RGA delete is non-undoable
|
|
159
|
+
* (tombstones are final; content is irrecoverable).
|
|
160
|
+
* clearRedo() is intentional: a destructive untracked op should invalidate
|
|
161
|
+
* any pending redo history, matching ProseMirror/Yjs behaviour.
|
|
162
|
+
*/
|
|
163
|
+
delete(pos: number, length: number, ttlMs?: number): void {
|
|
164
|
+
this.asRGA().delete(pos, length, ttlMs);
|
|
165
|
+
this.clearRedo();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Tree handle proxy methods ───────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
addNode(parentId: string | null, position: string, value: string, ttlMs?: number): string {
|
|
171
|
+
const handle = this.asTree();
|
|
172
|
+
const nodeId = handle.addNode(parentId, position, value, ttlMs);
|
|
173
|
+
const entry: TreeAddEntry = {
|
|
174
|
+
kind: "tree_add",
|
|
175
|
+
crdtId: this.getCrdtId(),
|
|
176
|
+
nodeId,
|
|
177
|
+
parentId,
|
|
178
|
+
position,
|
|
179
|
+
value,
|
|
180
|
+
};
|
|
181
|
+
this.record(entry);
|
|
182
|
+
this.clearRedo();
|
|
183
|
+
return nodeId;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
moveNode(nodeId: string, newParentId: string | null, newPosition: string, ttlMs?: number): void {
|
|
187
|
+
const handle = this.asTree();
|
|
188
|
+
const meta = handle.getNodeMeta(nodeId);
|
|
189
|
+
handle.moveNode(nodeId, newParentId, newPosition, ttlMs);
|
|
190
|
+
const entry: TreeMoveEntry = {
|
|
191
|
+
kind: "tree_move",
|
|
192
|
+
crdtId: this.getCrdtId(),
|
|
193
|
+
nodeId,
|
|
194
|
+
oldParentId: meta?.parentId ?? null,
|
|
195
|
+
oldPosition: meta?.position ?? "a0",
|
|
196
|
+
newParentId,
|
|
197
|
+
newPosition,
|
|
198
|
+
};
|
|
199
|
+
this.record(entry);
|
|
200
|
+
this.clearRedo();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateNode(nodeId: string, value: string, ttlMs?: number): void {
|
|
204
|
+
const handle = this.asTree();
|
|
205
|
+
const meta = handle.getNodeMeta(nodeId);
|
|
206
|
+
handle.updateNode(nodeId, value, ttlMs);
|
|
207
|
+
const entry: TreeUpdateEntry = {
|
|
208
|
+
kind: "tree_update",
|
|
209
|
+
crdtId: this.getCrdtId(),
|
|
210
|
+
nodeId,
|
|
211
|
+
previousValue: meta?.value ?? "",
|
|
212
|
+
previousUpdatedAt: meta?.updatedAt ?? nodeId,
|
|
213
|
+
newValue: value,
|
|
214
|
+
};
|
|
215
|
+
this.record(entry);
|
|
216
|
+
this.clearRedo();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
deleteNode(nodeId: string, ttlMs?: number): void {
|
|
220
|
+
const handle = this.asTree();
|
|
221
|
+
const meta = handle.getNodeMeta(nodeId);
|
|
222
|
+
handle.deleteNode(nodeId, ttlMs);
|
|
223
|
+
const entry: TreeDeleteEntry = {
|
|
224
|
+
kind: "tree_delete",
|
|
225
|
+
crdtId: this.getCrdtId(),
|
|
226
|
+
nodeId,
|
|
227
|
+
parentId: meta?.parentId ?? null,
|
|
228
|
+
position: meta?.position ?? "a0",
|
|
229
|
+
value: meta?.value ?? "",
|
|
230
|
+
updatedAt: meta?.updatedAt ?? nodeId,
|
|
231
|
+
};
|
|
232
|
+
this.record(entry);
|
|
233
|
+
this.clearRedo();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
private applyInverse(entry: UndoEntry): UndoEntry | null {
|
|
239
|
+
switch (entry.kind) {
|
|
240
|
+
case "rga_insert": {
|
|
241
|
+
this.asRGA().deleteById(entry.nodeId);
|
|
242
|
+
// Inverse of an insert is a delete — not re-recordable as an undo entry.
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case "tree_add": {
|
|
247
|
+
this.asTree().deleteNode(entry.nodeId);
|
|
248
|
+
// Inverse: a delete entry (for redo — re-add the node).
|
|
249
|
+
const inverse: TreeDeleteEntry = {
|
|
250
|
+
kind: "tree_delete",
|
|
251
|
+
crdtId: entry.crdtId,
|
|
252
|
+
nodeId: entry.nodeId,
|
|
253
|
+
parentId: entry.parentId,
|
|
254
|
+
position: entry.position,
|
|
255
|
+
value: entry.value,
|
|
256
|
+
updatedAt: entry.nodeId,
|
|
257
|
+
};
|
|
258
|
+
return inverse;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case "tree_delete": {
|
|
262
|
+
// Re-add with original state. Gets a new nodeId (tombstone is permanent).
|
|
263
|
+
const newNodeId = this.asTree().addNode(
|
|
264
|
+
entry.parentId,
|
|
265
|
+
entry.position,
|
|
266
|
+
entry.value,
|
|
267
|
+
);
|
|
268
|
+
const inverse: TreeAddEntry = {
|
|
269
|
+
kind: "tree_add",
|
|
270
|
+
crdtId: entry.crdtId,
|
|
271
|
+
nodeId: newNodeId,
|
|
272
|
+
parentId: entry.parentId,
|
|
273
|
+
position: entry.position,
|
|
274
|
+
value: entry.value,
|
|
275
|
+
};
|
|
276
|
+
return inverse;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "tree_move": {
|
|
280
|
+
this.asTree().moveNode(entry.nodeId, entry.oldParentId, entry.oldPosition);
|
|
281
|
+
const inverse: TreeMoveEntry = {
|
|
282
|
+
kind: "tree_move",
|
|
283
|
+
crdtId: entry.crdtId,
|
|
284
|
+
nodeId: entry.nodeId,
|
|
285
|
+
oldParentId: entry.newParentId,
|
|
286
|
+
oldPosition: entry.newPosition,
|
|
287
|
+
newParentId: entry.oldParentId,
|
|
288
|
+
newPosition: entry.oldPosition,
|
|
289
|
+
};
|
|
290
|
+
return inverse;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case "tree_update": {
|
|
294
|
+
this.asTree().updateNode(entry.nodeId, entry.previousValue);
|
|
295
|
+
const inverse: TreeUpdateEntry = {
|
|
296
|
+
kind: "tree_update",
|
|
297
|
+
crdtId: entry.crdtId,
|
|
298
|
+
nodeId: entry.nodeId,
|
|
299
|
+
previousValue: entry.newValue,
|
|
300
|
+
previousUpdatedAt: entry.previousUpdatedAt,
|
|
301
|
+
newValue: entry.previousValue,
|
|
302
|
+
};
|
|
303
|
+
return inverse;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private record(entry: UndoEntry): void {
|
|
309
|
+
if (this.openBatch !== null) {
|
|
310
|
+
this.openBatch.push(entry);
|
|
311
|
+
} else {
|
|
312
|
+
this.pushUndo({ entries: [entry] });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Record with 250ms debounce — consecutive calls within window share one batch. */
|
|
317
|
+
private recordWithDebounce(entry: UndoEntry): void {
|
|
318
|
+
if (this.debounceTimer === null) {
|
|
319
|
+
// Start a new debounce batch.
|
|
320
|
+
this.openBatch = [entry];
|
|
321
|
+
} else {
|
|
322
|
+
// Extend the current batch.
|
|
323
|
+
clearTimeout(this.debounceTimer);
|
|
324
|
+
this.openBatch?.push(entry);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
this.debounceTimer = setTimeout(() => {
|
|
328
|
+
this.debounceTimer = null;
|
|
329
|
+
this.commitBatch();
|
|
330
|
+
}, RGA_DEBOUNCE_MS);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private pushUndo(batch: UndoBatch): void {
|
|
334
|
+
this.undoStack.push(batch);
|
|
335
|
+
if (this.undoStack.length > MAX_STACK_DEPTH) {
|
|
336
|
+
this.undoStack.shift();
|
|
337
|
+
}
|
|
338
|
+
this.notifyStackChange();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private clearRedo(): void {
|
|
342
|
+
if (this.redoStack.length > 0) {
|
|
343
|
+
this.redoStack = [];
|
|
344
|
+
this.notifyStackChange();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private notifyStackChange(): void {
|
|
349
|
+
for (const listener of this.stackListeners) listener();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private asRGA(): RGAHandle {
|
|
353
|
+
if (!(this.handle instanceof RGAHandle)) {
|
|
354
|
+
throw new Error("UndoManager: handle is not an RGAHandle");
|
|
355
|
+
}
|
|
356
|
+
return this.handle;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private asTree(): TreeHandle {
|
|
360
|
+
if (!(this.handle instanceof TreeHandle)) {
|
|
361
|
+
throw new Error("UndoManager: handle is not a TreeHandle");
|
|
362
|
+
}
|
|
363
|
+
return this.handle;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private getCrdtId(): string {
|
|
367
|
+
return this.handle.id;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo/redo entry types for CRDT-aware per-client history.
|
|
3
|
+
*
|
|
4
|
+
* Each entry stores enough information to produce the inverse operation.
|
|
5
|
+
* RGA Delete is intentionally not recorded — tombstones are final and
|
|
6
|
+
* the content is not recoverable (same semantics as ProseMirror/Yjs).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RgaInsertEntry {
|
|
10
|
+
readonly kind: "rga_insert";
|
|
11
|
+
readonly crdtId: string;
|
|
12
|
+
/** HLC string "wall_ms:logical:node_id" of the inserted node. Undo = deleteById(nodeId). */
|
|
13
|
+
readonly nodeId: string;
|
|
14
|
+
/** Original visible position — used for redo re-insert. */
|
|
15
|
+
readonly pos: number;
|
|
16
|
+
/** The character that was inserted. */
|
|
17
|
+
readonly content: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TreeAddEntry {
|
|
21
|
+
readonly kind: "tree_add";
|
|
22
|
+
readonly crdtId: string;
|
|
23
|
+
/** Node ID returned by addNode(). Undo = deleteNode(nodeId). */
|
|
24
|
+
readonly nodeId: string;
|
|
25
|
+
/** Stored for redo: re-add with same parent/position/value. */
|
|
26
|
+
readonly parentId: string | null;
|
|
27
|
+
readonly position: string;
|
|
28
|
+
readonly value: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TreeDeleteEntry {
|
|
32
|
+
readonly kind: "tree_delete";
|
|
33
|
+
readonly crdtId: string;
|
|
34
|
+
readonly nodeId: string;
|
|
35
|
+
/** State captured before the delete — used to reconstruct addNode for undo. */
|
|
36
|
+
readonly parentId: string | null;
|
|
37
|
+
readonly position: string;
|
|
38
|
+
readonly value: string;
|
|
39
|
+
readonly updatedAt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TreeMoveEntry {
|
|
43
|
+
readonly kind: "tree_move";
|
|
44
|
+
readonly crdtId: string;
|
|
45
|
+
readonly nodeId: string;
|
|
46
|
+
readonly oldParentId: string | null;
|
|
47
|
+
readonly oldPosition: string;
|
|
48
|
+
/** Stored for redo. */
|
|
49
|
+
readonly newParentId: string | null;
|
|
50
|
+
readonly newPosition: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TreeUpdateEntry {
|
|
54
|
+
readonly kind: "tree_update";
|
|
55
|
+
readonly crdtId: string;
|
|
56
|
+
readonly nodeId: string;
|
|
57
|
+
/** Captured before the update — used for undo. */
|
|
58
|
+
readonly previousValue: string;
|
|
59
|
+
readonly previousUpdatedAt: string;
|
|
60
|
+
/** Stored for redo. */
|
|
61
|
+
readonly newValue: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type UndoEntry =
|
|
65
|
+
| RgaInsertEntry
|
|
66
|
+
| TreeAddEntry
|
|
67
|
+
| TreeDeleteEntry
|
|
68
|
+
| TreeMoveEntry
|
|
69
|
+
| TreeUpdateEntry;
|
|
70
|
+
|
|
71
|
+
/** A batch groups one or more entries into a single logical undo step. */
|
|
72
|
+
export interface UndoBatch {
|
|
73
|
+
readonly entries: readonly UndoEntry[];
|
|
74
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fractional indexing helpers for TreeCRDT sibling ordering.
|
|
3
|
+
*
|
|
4
|
+
* Positions are lexicographically ordered strings over the alphabet [a-z0-9].
|
|
5
|
+
* They can be compared with plain string comparison: "a0" < "b0" < "z9".
|
|
6
|
+
*
|
|
7
|
+
* Use these helpers to compute positions when inserting nodes between, before,
|
|
8
|
+
* or after existing siblings — without having to manage the alphabet manually.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { fi } from "meridian-sdk";
|
|
13
|
+
*
|
|
14
|
+
* const first = fi.start(); // "a0"
|
|
15
|
+
* const last = fi.end(); // "z9"
|
|
16
|
+
* const mid = fi.between("a0","z9"); // "m4" (midpoint)
|
|
17
|
+
* const prev = fi.before("a0"); // "V9" (below a0)
|
|
18
|
+
* const next = fi.after("z9"); // "{0" (above z9, still valid)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Characters used in fractional index strings, in ascending order. */
|
|
23
|
+
const CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
24
|
+
const BASE = BigInt(CHARS.length); // 36
|
|
25
|
+
|
|
26
|
+
/** Encode a position string to a BigInt for arithmetic. */
|
|
27
|
+
function toNum(s: string): bigint {
|
|
28
|
+
let n = 0n;
|
|
29
|
+
for (const c of s) {
|
|
30
|
+
const idx = CHARS.indexOf(c);
|
|
31
|
+
if (idx === -1) throw new Error(`Invalid fractional index character: "${c}"`);
|
|
32
|
+
n = n * BASE + BigInt(idx);
|
|
33
|
+
}
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Decode a BigInt back to a fixed-length position string. */
|
|
38
|
+
function toStr(n: bigint, len: number): string {
|
|
39
|
+
let s = "";
|
|
40
|
+
for (let i = 0; i < len; i++) {
|
|
41
|
+
s = (CHARS[Number(n % BASE)] ?? "0") + s;
|
|
42
|
+
n = n / BASE;
|
|
43
|
+
}
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Increment a position string by 1 in base-36. */
|
|
48
|
+
function increment(s: string): string {
|
|
49
|
+
const n = toNum(s) + 1n;
|
|
50
|
+
// If the result requires more digits, extend by one char
|
|
51
|
+
const maxForLen = BASE ** BigInt(s.length) - 1n;
|
|
52
|
+
if (n > maxForLen) return toStr(n, s.length + 1);
|
|
53
|
+
return toStr(n, s.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decrement a position string by 1 in base-36, trimming trailing zeros. */
|
|
57
|
+
function decrement(s: string): string {
|
|
58
|
+
const n = toNum(s) - 1n;
|
|
59
|
+
let result = toStr(n < 0n ? 0n : n, s.length);
|
|
60
|
+
// Trim trailing zeros beyond length 1
|
|
61
|
+
while (result.length > 1 && result.endsWith("0")) result = result.slice(0, -1);
|
|
62
|
+
// If underflow, prepend a zero digit
|
|
63
|
+
if (n < 0n) return "0" + result;
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a position string strictly between `a` and `b`.
|
|
69
|
+
*
|
|
70
|
+
* Both `a` and `b` must be valid fractional index strings, and `a < b`
|
|
71
|
+
* lexicographically. Uses BigInt arithmetic to find the exact midpoint,
|
|
72
|
+
* appending an extra character when the gap is too small.
|
|
73
|
+
*
|
|
74
|
+
* @param a - Lower bound (exclusive).
|
|
75
|
+
* @param b - Upper bound (exclusive).
|
|
76
|
+
*/
|
|
77
|
+
export function between(a: string, b: string): string {
|
|
78
|
+
if (a >= b) throw new Error(`between: a must be < b (got "${a}" >= "${b}")`);
|
|
79
|
+
|
|
80
|
+
// Pad both to the same length + 1 extra digit for precision
|
|
81
|
+
const len = Math.max(a.length, b.length) + 1;
|
|
82
|
+
const aPadded = a.padEnd(len, "0");
|
|
83
|
+
const bPadded = b.padEnd(len, "0");
|
|
84
|
+
|
|
85
|
+
const an = toNum(aPadded);
|
|
86
|
+
const bn = toNum(bPadded);
|
|
87
|
+
const mid = (an + bn) / 2n;
|
|
88
|
+
|
|
89
|
+
let result = toStr(mid, len);
|
|
90
|
+
// Trim trailing zeros for cleaner output (keep min length 1)
|
|
91
|
+
while (result.length > 1 && result.endsWith("0")) result = result.slice(0, -1);
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns a position string that sorts before `a`.
|
|
97
|
+
*
|
|
98
|
+
* @param a - The reference position.
|
|
99
|
+
*/
|
|
100
|
+
export function before(a: string): string {
|
|
101
|
+
return decrement(a);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Returns a position string that sorts after `b`.
|
|
106
|
+
*
|
|
107
|
+
* @param b - The reference position.
|
|
108
|
+
*/
|
|
109
|
+
export function after(b: string): string {
|
|
110
|
+
return increment(b);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the canonical start position — the lowest recommended initial value.
|
|
115
|
+
*/
|
|
116
|
+
export function start(): string {
|
|
117
|
+
return "a0";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns the canonical end position — the highest recommended initial value.
|
|
122
|
+
*/
|
|
123
|
+
export function end(): string {
|
|
124
|
+
return "z9";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Computes an array of `n` evenly-spaced positions between `a` and `b`
|
|
129
|
+
* (exclusive). Useful for bulk-inserting nodes in order.
|
|
130
|
+
*
|
|
131
|
+
* @param a - Lower bound (exclusive).
|
|
132
|
+
* @param b - Upper bound (exclusive).
|
|
133
|
+
* @param n - Number of positions to generate.
|
|
134
|
+
*/
|
|
135
|
+
export function spread(a: string, b: string, n: number): string[] {
|
|
136
|
+
if (n <= 0) return [];
|
|
137
|
+
if (n === 1) return [between(a, b)];
|
|
138
|
+
|
|
139
|
+
const positions: string[] = [];
|
|
140
|
+
let lo = a;
|
|
141
|
+
for (let i = 0; i < n; i++) {
|
|
142
|
+
// Divide the remaining range into (n - i) equal slots
|
|
143
|
+
const remaining = n - i;
|
|
144
|
+
// Approximate: compute a position 1/(remaining+1) through [lo, b]
|
|
145
|
+
// We do this iteratively by bisecting
|
|
146
|
+
let hi = b;
|
|
147
|
+
for (let step = 0; step < Math.ceil(Math.log2(remaining + 1)); step++) {
|
|
148
|
+
hi = between(lo, b);
|
|
149
|
+
}
|
|
150
|
+
const pos = between(lo, b);
|
|
151
|
+
positions.push(pos);
|
|
152
|
+
lo = pos;
|
|
153
|
+
}
|
|
154
|
+
return positions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Namespace object for convenient import.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* import { fi } from "meridian-sdk";
|
|
163
|
+
* const pos = fi.between("a0", "z9");
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export const fi = { between, before, after, start, end, spread };
|