meridian-sdk 1.2.1 → 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.
Files changed (66) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/agents.d.ts +143 -8
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +141 -14
  5. package/dist/agents.js.map +1 -1
  6. package/dist/client.d.ts +7 -2
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/client.js.map +1 -1
  10. package/dist/conflict/types.d.ts +54 -0
  11. package/dist/conflict/types.d.ts.map +1 -0
  12. package/dist/conflict/types.js +9 -0
  13. package/dist/conflict/types.js.map +1 -0
  14. package/dist/crdt/rga.d.ts +20 -4
  15. package/dist/crdt/rga.d.ts.map +1 -1
  16. package/dist/crdt/rga.js +42 -6
  17. package/dist/crdt/rga.js.map +1 -1
  18. package/dist/crdt/tree.d.ts +45 -0
  19. package/dist/crdt/tree.d.ts.map +1 -1
  20. package/dist/crdt/tree.js +135 -10
  21. package/dist/crdt/tree.js.map +1 -1
  22. package/dist/index.d.ts +10 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/schema.d.ts +63 -3
  27. package/dist/schema.d.ts.map +1 -1
  28. package/dist/schema.js +18 -1
  29. package/dist/schema.js.map +1 -1
  30. package/dist/sync/delta.d.ts +24 -0
  31. package/dist/sync/delta.d.ts.map +1 -1
  32. package/dist/sync/delta.js +1 -1
  33. package/dist/sync/delta.js.map +1 -1
  34. package/dist/undo/UndoManager.d.ts +74 -0
  35. package/dist/undo/UndoManager.d.ts.map +1 -0
  36. package/dist/undo/UndoManager.js +318 -0
  37. package/dist/undo/UndoManager.js.map +1 -0
  38. package/dist/undo/types.d.ts +63 -0
  39. package/dist/undo/types.d.ts.map +1 -0
  40. package/dist/undo/types.js +9 -0
  41. package/dist/undo/types.js.map +1 -0
  42. package/dist/utils/fractional.d.ts +78 -0
  43. package/dist/utils/fractional.d.ts.map +1 -0
  44. package/dist/utils/fractional.js +159 -0
  45. package/dist/utils/fractional.js.map +1 -0
  46. package/dist/validation/index.d.ts +107 -0
  47. package/dist/validation/index.d.ts.map +1 -0
  48. package/dist/validation/index.js +123 -0
  49. package/dist/validation/index.js.map +1 -0
  50. package/package.json +1 -1
  51. package/src/agents.ts +224 -15
  52. package/src/client.ts +5 -4
  53. package/src/conflict/types.ts +60 -0
  54. package/src/crdt/rga.ts +46 -7
  55. package/src/crdt/tree.ts +164 -0
  56. package/src/index.ts +28 -1
  57. package/src/schema.ts +24 -1
  58. package/src/sync/delta.ts +15 -2
  59. package/src/undo/UndoManager.ts +369 -0
  60. package/src/undo/types.ts +74 -0
  61. package/src/utils/fractional.ts +166 -0
  62. package/src/validation/index.ts +137 -0
  63. package/test/conflict.test.ts +242 -0
  64. package/test/fractional.test.ts +127 -0
  65. package/test/undo.test.ts +272 -0
  66. package/test/validation.test.ts +137 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Unit tests for UndoManager — CRDT-aware per-client undo/redo.
3
+ *
4
+ * No server required — transport is stubbed. Tests verify that undo/redo
5
+ * dispatch the correct inverse CrdtOps via the transport.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach } from "bun:test";
9
+ import { RGAHandle } from "../src/crdt/rga.js";
10
+ import { TreeHandle } from "../src/crdt/tree.js";
11
+ import { UndoManager } from "../src/undo/UndoManager.js";
12
+ import type { WsTransport } from "../src/transport/websocket.js";
13
+ import { decode } from "@msgpack/msgpack";
14
+
15
+ function stubTransport(): WsTransport & { sent: unknown[] } {
16
+ const sent: unknown[] = [];
17
+ return {
18
+ sent,
19
+ connect: () => {},
20
+ close: () => {},
21
+ subscribe: () => {},
22
+ updateClock: () => {},
23
+ send: (msg: unknown) => { sent.push(msg); },
24
+ get currentState() { return "CONNECTED" as const; },
25
+ } as unknown as WsTransport & { sent: unknown[] };
26
+ }
27
+
28
+ function decodeOp(msg: unknown): unknown {
29
+ const m = msg as { Op: { op_bytes: Uint8Array } };
30
+ return decode(m.Op.op_bytes);
31
+ }
32
+
33
+ const OPTS = { crdtId: "rg:doc", clientId: 42 };
34
+ const TREE_OPTS = { crdtId: "tr:doc", clientId: 42 };
35
+
36
+ // ─── RGA ─────────────────────────────────────────────────────────────────────
37
+
38
+ describe("UndoManager — RGA", () => {
39
+ let t: WsTransport & { sent: unknown[] };
40
+ let handle: RGAHandle;
41
+ let manager: UndoManager<RGAHandle>;
42
+
43
+ beforeEach(() => {
44
+ t = stubTransport();
45
+ handle = new RGAHandle({ ...OPTS, transport: t });
46
+ manager = new UndoManager(handle);
47
+ });
48
+
49
+ it("insert returns node HLC strings", () => {
50
+ const ids = manager.insert(0, "hi");
51
+ expect(ids).toHaveLength(2);
52
+ expect(ids[0]).toMatch(/^\d+:0:42$/);
53
+ expect(ids[1]).toMatch(/^\d+:1:42$/);
54
+ });
55
+
56
+ it("undo after insert sends deleteById for each inserted node", async () => {
57
+ const ids = manager.insert(0, "ab");
58
+ expect(t.sent).toHaveLength(2); // 2 inserts
59
+
60
+ // Wait for debounce to commit the batch
61
+ await new Promise(r => setTimeout(r, 300));
62
+
63
+ expect(manager.canUndo).toBe(true);
64
+ manager.undo();
65
+
66
+ // 2 more sends: deleteById for each char (in reverse order)
67
+ expect(t.sent).toHaveLength(4);
68
+ const del1 = decodeOp(t.sent[3]) as { RGA: { Delete: { id: unknown } } };
69
+ const del2 = decodeOp(t.sent[2]) as { RGA: { Delete: { id: unknown } } };
70
+ expect(del1.RGA.Delete.id).toBeDefined();
71
+ expect(del2.RGA.Delete.id).toBeDefined();
72
+ });
73
+
74
+ it("undo with correct HLC — does not target remote nodes", async () => {
75
+ const ids = manager.insert(0, "x");
76
+ await new Promise(r => setTimeout(r, 300));
77
+
78
+ // Simulate remote delta — server sends back authoritative text
79
+ handle.applyDelta({ text: "yx" }); // remote "y" prepended
80
+
81
+ manager.undo();
82
+ // Delete op should target our HLC id, not the remote one
83
+ const del = decodeOp(t.sent[t.sent.length - 1]) as {
84
+ RGA: { Delete: { id: { node_id: number } } };
85
+ };
86
+ expect(del.RGA.Delete.id.node_id).toBe(42); // our clientId
87
+ });
88
+
89
+ it("undo on empty stack is a no-op", () => {
90
+ expect(manager.canUndo).toBe(false);
91
+ manager.undo(); // should not throw
92
+ expect(t.sent).toHaveLength(0);
93
+ });
94
+
95
+ it("RGA delete is not recorded — undo does nothing", () => {
96
+ manager.insert(0, "hello");
97
+ manager.delete(0, 5);
98
+ // delete sends pos-based ops (non-undoable) — stack only has insert batch
99
+ // Wait for debounce
100
+ // canUndo might be true from insert, but redo after undo of just-delete is empty
101
+ expect(manager.canRedo).toBe(false);
102
+ });
103
+
104
+ it("batch debounce: 3 inserts within 250ms form one undo step", async () => {
105
+ manager.insert(0, "a");
106
+ manager.insert(1, "b");
107
+ manager.insert(2, "c");
108
+ // All within debounce window — still one pending batch
109
+ expect(manager.canUndo).toBe(false); // not committed yet
110
+
111
+ await new Promise(r => setTimeout(r, 300));
112
+ expect(manager.canUndo).toBe(true); // now committed as one batch
113
+
114
+ manager.undo();
115
+ // One undo step = 3 deletes
116
+ expect(t.sent).toHaveLength(6); // 3 inserts + 3 deletes
117
+ });
118
+
119
+ it("canUndo / canRedo reflect stack state", async () => {
120
+ expect(manager.canUndo).toBe(false);
121
+ expect(manager.canRedo).toBe(false);
122
+
123
+ manager.insert(0, "x");
124
+ await new Promise(r => setTimeout(r, 300));
125
+ expect(manager.canUndo).toBe(true);
126
+ expect(manager.canRedo).toBe(false);
127
+
128
+ manager.undo();
129
+ // RGA insert undo = deleteById, which returns null inverse — canRedo stays false
130
+ expect(manager.canUndo).toBe(false);
131
+ expect(manager.canRedo).toBe(false);
132
+ });
133
+
134
+ it("new op after undo clears redoStack — tree example", () => {
135
+ // Use TreeHandle for this test since RGA undo doesn't produce redoStack entries
136
+ const t2 = stubTransport();
137
+ const h2 = new TreeHandle({ ...TREE_OPTS, clientId: 42, transport: t2 });
138
+ const m2 = new UndoManager(h2);
139
+
140
+ m2.addNode(null, "a0", "A");
141
+ m2.undo();
142
+ expect(m2.canRedo).toBe(true);
143
+
144
+ m2.addNode(null, "a0", "B"); // new op after undo
145
+ expect(m2.canRedo).toBe(false);
146
+ });
147
+
148
+ it("onStackChange fires on undo and redo", async () => {
149
+ const calls: string[] = [];
150
+ manager.onStackChange(() => calls.push("change"));
151
+
152
+ manager.insert(0, "z");
153
+ await new Promise(r => setTimeout(r, 300));
154
+ // debounce commit fires onStackChange
155
+ expect(calls.length).toBeGreaterThanOrEqual(1);
156
+
157
+ const prev = calls.length;
158
+ manager.undo();
159
+ expect(calls.length).toBe(prev + 1);
160
+ });
161
+ });
162
+
163
+ // ─── Tree ─────────────────────────────────────────────────────────────────────
164
+
165
+ describe("UndoManager — Tree", () => {
166
+ let t: WsTransport & { sent: unknown[] };
167
+ let handle: TreeHandle;
168
+ let manager: UndoManager<TreeHandle>;
169
+
170
+ beforeEach(() => {
171
+ t = stubTransport();
172
+ handle = new TreeHandle({ ...TREE_OPTS, transport: t });
173
+ manager = new UndoManager(handle);
174
+ });
175
+
176
+ it("undo after addNode sends deleteNode for that nodeId", () => {
177
+ const nodeId = manager.addNode(null, "a0", "Root");
178
+ expect(manager.canUndo).toBe(true);
179
+
180
+ manager.undo();
181
+ expect(t.sent).toHaveLength(2);
182
+ const del = decodeOp(t.sent[1]) as { Tree: { DeleteNode: unknown } };
183
+ expect(del.Tree.DeleteNode).toBeDefined();
184
+ });
185
+
186
+ it("undo after deleteNode sends addNode with original parent/position/value", () => {
187
+ const nodeId = manager.addNode(null, "a0", "Root");
188
+ t.sent.splice(0); // clear
189
+
190
+ manager.deleteNode(nodeId);
191
+ expect(t.sent).toHaveLength(1); // 1 deleteNode op
192
+
193
+ manager.undo(); // should add a new node
194
+ expect(t.sent).toHaveLength(2);
195
+ const add = decodeOp(t.sent[1]) as { Tree: { AddNode: { value: string } } };
196
+ expect(add.Tree.AddNode.value).toBe("Root");
197
+ });
198
+
199
+ it("undo after moveNode sends moveNode back to old parent", () => {
200
+ const parentId = manager.addNode(null, "a0", "Parent");
201
+ const childId = manager.addNode(null, "b0", "Child");
202
+ t.sent.splice(0);
203
+
204
+ manager.moveNode(childId, parentId, "a0");
205
+ expect(t.sent).toHaveLength(1);
206
+
207
+ manager.undo();
208
+ expect(t.sent).toHaveLength(2);
209
+ const move = decodeOp(t.sent[1]) as {
210
+ Tree: { MoveNode: { new_parent_id: null | unknown } };
211
+ };
212
+ // Undo move: child should go back to root (null parent)
213
+ expect(move.Tree.MoveNode.new_parent_id).toBeNull();
214
+ });
215
+
216
+ it("undo after updateNode sends updateNode with previous value", () => {
217
+ const nodeId = manager.addNode(null, "a0", "Original");
218
+ t.sent.splice(0);
219
+
220
+ manager.updateNode(nodeId, "Updated");
221
+ expect(t.sent).toHaveLength(1);
222
+
223
+ manager.undo();
224
+ expect(t.sent).toHaveLength(2);
225
+ const update = decodeOp(t.sent[1]) as {
226
+ Tree: { UpdateNode: { value: string } };
227
+ };
228
+ expect(update.Tree.UpdateNode.value).toBe("Original");
229
+ });
230
+
231
+ it("redo after undo(addNode) re-adds the node", () => {
232
+ manager.addNode(null, "a0", "Root");
233
+ manager.undo();
234
+ expect(manager.canRedo).toBe(true);
235
+
236
+ manager.redo();
237
+ // redo of tree_delete = addNode (new nodeId)
238
+ expect(t.sent).toHaveLength(3); // original add + delete + re-add
239
+ const add = decodeOp(t.sent[2]) as { Tree: { AddNode: { value: string } } };
240
+ expect(add.Tree.AddNode.value).toBe("Root");
241
+ });
242
+
243
+ it("manual batch: addNode + updateNode = one undo step", () => {
244
+ manager.startBatch();
245
+ const nodeId = manager.addNode(null, "a0", "Hello");
246
+ manager.updateNode(nodeId, "Hello World");
247
+ manager.commitBatch();
248
+
249
+ expect(manager.canUndo).toBe(true);
250
+ // One undo step undoes both ops
251
+ manager.undo();
252
+ expect(t.sent).toHaveLength(4); // add + update + delete + update-back
253
+ });
254
+
255
+ it("remote delta between op and undo does not affect target HLC", () => {
256
+ const nodeId = manager.addNode(null, "a0", "A");
257
+
258
+ // Remote delta: server adds another node
259
+ handle.applyDelta({
260
+ roots: [
261
+ { id: nodeId, value: "A", children: [] },
262
+ { id: "9999:0:99", value: "Remote", children: [] },
263
+ ],
264
+ });
265
+
266
+ manager.undo(); // should only delete our nodeId
267
+ const del = decodeOp(t.sent[t.sent.length - 1]) as {
268
+ Tree: { DeleteNode: { id: { node_id: number } } };
269
+ };
270
+ expect(del.Tree.DeleteNode.id.node_id).toBe(42); // our clientId
271
+ });
272
+ });
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { TreeHandle } from "../src/crdt/tree.js";
3
+ import { RGAHandle } from "../src/crdt/rga.js";
4
+ import { CrdtValidationError, zodValidator, fnValidator } from "../src/validation/index.js";
5
+ import type { WsTransport } from "../src/transport/websocket.js";
6
+
7
+ function stubTransport(): WsTransport {
8
+ return {
9
+ connect: () => {},
10
+ close: () => {},
11
+ send: () => {},
12
+ onStateChange: () => () => {},
13
+ onMessage: () => () => {},
14
+ pendingCount: () => 0,
15
+ } as unknown as WsTransport;
16
+ }
17
+
18
+ function makeTree(validator?: Parameters<typeof TreeHandle.prototype.constructor>[0]["validator"]) {
19
+ return new TreeHandle({ crdtId: "t", clientId: 1, transport: stubTransport(), validator });
20
+ }
21
+
22
+ function makeRga(validator?: Parameters<typeof RGAHandle.prototype.constructor>[0]["validator"]) {
23
+ return new RGAHandle({ crdtId: "r", clientId: 1, transport: stubTransport(), validator });
24
+ }
25
+
26
+ // ── fnValidator ──────────────────────────────────────────────────────────────
27
+
28
+ describe("fnValidator", () => {
29
+ it("accepts when fn returns true", () => {
30
+ const v = fnValidator(() => true);
31
+ expect(() => {
32
+ const tree = makeTree(v);
33
+ tree.addNode(null, "a0", "anything");
34
+ }).not.toThrow();
35
+ });
36
+
37
+ it("rejects when fn returns false", () => {
38
+ const v = fnValidator(() => false);
39
+ const tree = makeTree(v);
40
+ expect(() => tree.addNode(null, "a0", "x")).toThrow(CrdtValidationError);
41
+ });
42
+
43
+ it("uses string return as error message", () => {
44
+ const v = fnValidator(() => "must be non-empty");
45
+ const tree = makeTree(v);
46
+ let err: unknown;
47
+ try { tree.addNode(null, "a0", ""); } catch (e) { err = e; }
48
+ expect(err).toBeInstanceOf(CrdtValidationError);
49
+ expect((err as CrdtValidationError).reason).toBe("must be non-empty");
50
+ });
51
+
52
+ it("captures thrown errors as reason", () => {
53
+ const v = fnValidator(() => { throw new Error("boom"); });
54
+ const tree = makeTree(v);
55
+ let err: unknown;
56
+ try { tree.addNode(null, "a0", "x"); } catch (e) { err = e; }
57
+ expect(err).toBeInstanceOf(CrdtValidationError);
58
+ expect((err as CrdtValidationError).reason).toBe("boom");
59
+ });
60
+ });
61
+
62
+ // ── zodValidator ─────────────────────────────────────────────────────────────
63
+
64
+ describe("zodValidator", () => {
65
+ // Minimal stub matching Zod's safeParse API — no zod dependency needed
66
+ const schema = {
67
+ safeParse(value: unknown) {
68
+ if (typeof value === "object" && value !== null && "title" in value && typeof (value as Record<string, unknown>)["title"] === "string") {
69
+ return { success: true as const };
70
+ }
71
+ return { success: false as const, error: { message: "title must be a string" } };
72
+ },
73
+ };
74
+
75
+ it("accepts valid JSON matching schema", () => {
76
+ const tree = makeTree(zodValidator(schema));
77
+ expect(() => tree.addNode(null, "a0", JSON.stringify({ title: "Task" }))).not.toThrow();
78
+ });
79
+
80
+ it("rejects invalid JSON value", () => {
81
+ const tree = makeTree(zodValidator(schema));
82
+ expect(() => tree.addNode(null, "a0", JSON.stringify({ title: 42 }))).toThrow(CrdtValidationError);
83
+ });
84
+
85
+ it("passes raw string to schema when not valid JSON", () => {
86
+ // Schema rejects non-objects — plain string "hello" fails
87
+ const tree = makeTree(zodValidator(schema));
88
+ expect(() => tree.addNode(null, "a0", "hello")).toThrow(CrdtValidationError);
89
+ });
90
+ });
91
+
92
+ // ── TreeHandle integration ───────────────────────────────────────────────────
93
+
94
+ describe("TreeHandle validation", () => {
95
+ it("validates addNode value", () => {
96
+ const tree = makeTree(fnValidator((v) => v.length > 0 || "empty"));
97
+ expect(() => tree.addNode(null, "a0", "")).toThrow(CrdtValidationError);
98
+ expect(() => tree.addNode(null, "a0", "valid")).not.toThrow();
99
+ });
100
+
101
+ it("validates updateNode value", () => {
102
+ const tree = makeTree(fnValidator((v) => v !== "banned" || "banned word"));
103
+ const id = tree.addNode(null, "a0", "ok");
104
+ expect(() => tree.updateNode(id, "banned")).toThrow(CrdtValidationError);
105
+ expect(() => tree.updateNode(id, "fine")).not.toThrow();
106
+ });
107
+
108
+ it("no validator — all values accepted", () => {
109
+ const tree = makeTree();
110
+ expect(() => tree.addNode(null, "a0", "")).not.toThrow();
111
+ expect(() => tree.addNode(null, "a0", "anything")).not.toThrow();
112
+ });
113
+ });
114
+
115
+ // ── RGAHandle integration ────────────────────────────────────────────────────
116
+
117
+ describe("RGAHandle validation", () => {
118
+ it("validates insert text", () => {
119
+ const rga = makeRga(fnValidator((v) => !v.includes("<") || "no HTML"));
120
+ expect(() => rga.insert(0, "<script>")).toThrow(CrdtValidationError);
121
+ expect(() => rga.insert(0, "hello")).not.toThrow();
122
+ });
123
+
124
+ it("no validator — all text accepted", () => {
125
+ const rga = makeRga();
126
+ expect(() => rga.insert(0, "<anything>")).not.toThrow();
127
+ });
128
+
129
+ it("CrdtValidationError has value and reason fields", () => {
130
+ const rga = makeRga(fnValidator(() => "nope"));
131
+ let err: unknown;
132
+ try { rga.insert(0, "bad"); } catch (e) { err = e; }
133
+ expect(err).toBeInstanceOf(CrdtValidationError);
134
+ expect((err as CrdtValidationError).value).toBe("bad");
135
+ expect((err as CrdtValidationError).reason).toBe("nope");
136
+ });
137
+ });