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,137 @@
1
+ /**
2
+ * Runtime validation for CRDT string values.
3
+ *
4
+ * Values stored in TreeCRDT and RGA are always strings on the wire. This
5
+ * layer lets you attach a validator to a handle so that ops are checked
6
+ * before being sent. Invalid values throw a `CrdtValidationError`.
7
+ *
8
+ * The validator interface is intentionally minimal — plug in Zod, Valibot,
9
+ * ArkType, or a plain function without any SDK dependency on those libraries.
10
+ *
11
+ * @example Using Zod:
12
+ * ```ts
13
+ * import { z } from "zod";
14
+ * import { zodValidator } from "meridian-sdk";
15
+ *
16
+ * const schema = z.object({ title: z.string(), done: z.boolean() });
17
+ * const tree = client.tree("tasks", { validator: zodValidator(schema) });
18
+ *
19
+ * // Validated — value is parsed JSON then checked against schema
20
+ * tree.addNode(null, "a0", JSON.stringify({ title: "Task 1", done: false }));
21
+ *
22
+ * // Throws CrdtValidationError
23
+ * tree.addNode(null, "a0", JSON.stringify({ title: 42 }));
24
+ * ```
25
+ *
26
+ * @example Using a plain function:
27
+ * ```ts
28
+ * import { fnValidator } from "meridian-sdk";
29
+ *
30
+ * const tree = client.tree("tasks", {
31
+ * validator: fnValidator((v) => typeof v === "string" && v.length > 0),
32
+ * });
33
+ * ```
34
+ */
35
+
36
+ /** Thrown when a CRDT value fails validation before being sent. */
37
+ export class CrdtValidationError extends Error {
38
+ override readonly name = "CrdtValidationError";
39
+ constructor(
40
+ /** The raw string value that failed. */
41
+ public readonly value: string,
42
+ /** Human-readable reason from the validator. */
43
+ public readonly reason: string,
44
+ ) {
45
+ super(`CrdtValidationError: ${reason} (value: ${JSON.stringify(value)})`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * A validator that can be attached to a CRDT handle.
51
+ *
52
+ * `validate` receives the raw string value as stored on the wire. It should
53
+ * throw or return `{ ok: false, error: string }` for invalid values, and
54
+ * return `{ ok: true }` or `undefined` for valid ones.
55
+ */
56
+ export interface CrdtValidator {
57
+ /**
58
+ * Validate `value`. Throw, or return `{ ok: false, error }` to reject.
59
+ * Return `{ ok: true }` or `undefined` to accept.
60
+ */
61
+ validate(value: string): { ok: false; error: string } | { ok: true } | undefined | void;
62
+ }
63
+
64
+ /**
65
+ * Run a validator against a value. Throws `CrdtValidationError` on failure.
66
+ * No-ops if `validator` is undefined.
67
+ */
68
+ export function runValidator(validator: CrdtValidator | undefined, value: string): void {
69
+ if (validator === undefined) return;
70
+ let result: ReturnType<CrdtValidator["validate"]>;
71
+ try {
72
+ result = validator.validate(value);
73
+ } catch (err) {
74
+ const reason = err instanceof Error ? err.message : String(err);
75
+ throw new CrdtValidationError(value, reason);
76
+ }
77
+ if (result !== undefined && result !== null && !result.ok) {
78
+ throw new CrdtValidationError(value, result.error);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Creates a `CrdtValidator` from any Zod schema (or any object with a
84
+ * `safeParse` method returning `{ success, error }`).
85
+ *
86
+ * The string value is JSON-parsed before being passed to the schema.
87
+ * If JSON parsing fails, the raw string is passed as-is.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * import { z } from "zod";
92
+ * const validator = zodValidator(z.object({ title: z.string() }));
93
+ * ```
94
+ */
95
+ export function zodValidator(schema: {
96
+ safeParse(value: unknown): { success: true } | { success: false; error: { message: string } };
97
+ }): CrdtValidator {
98
+ return {
99
+ validate(raw: string) {
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(raw);
103
+ } catch {
104
+ parsed = raw;
105
+ }
106
+ const result = schema.safeParse(parsed);
107
+ if (!result.success) {
108
+ return { ok: false, error: result.error.message };
109
+ }
110
+ return { ok: true };
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Creates a `CrdtValidator` from a plain predicate function.
117
+ *
118
+ * The function receives the raw string value. Return `true` to accept,
119
+ * `false` or a string (used as the error message) to reject.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * const validator = fnValidator((v) => v.length > 0 || "Value must not be empty");
124
+ * ```
125
+ */
126
+ export function fnValidator(
127
+ fn: (value: string) => boolean | string,
128
+ ): CrdtValidator {
129
+ return {
130
+ validate(raw: string) {
131
+ const result = fn(raw);
132
+ if (result === true) return { ok: true };
133
+ if (result === false) return { ok: false, error: "Validation failed" };
134
+ return { ok: false, error: result };
135
+ },
136
+ };
137
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Unit tests for TreeHandle conflict visualization.
3
+ *
4
+ * Conflict events are detected by comparing the local optimistic state
5
+ * (nodeMetaMap) against the incoming server delta. Transport is stubbed.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach } from "bun:test";
9
+ import { TreeHandle } from "../src/crdt/tree.js";
10
+ import type { WsTransport } from "../src/transport/websocket.js";
11
+ import type { ConflictEvent } from "../src/conflict/types.js";
12
+ import type { TreeDelta } from "../src/sync/delta.js";
13
+
14
+ function stubTransport(): WsTransport & { sent: unknown[] } {
15
+ const sent: unknown[] = [];
16
+ return {
17
+ sent,
18
+ connect: () => {},
19
+ close: () => {},
20
+ send: (msg) => { sent.push(msg); },
21
+ onStateChange: () => () => {},
22
+ onMessage: () => () => {},
23
+ pendingCount: () => 0,
24
+ } as unknown as WsTransport & { sent: unknown[] };
25
+ }
26
+
27
+ function makeTree(clientId = 1): TreeHandle {
28
+ return new TreeHandle({
29
+ crdtId: "tree-1",
30
+ clientId,
31
+ transport: stubTransport(),
32
+ });
33
+ }
34
+
35
+ /** Helper: build a flat TreeDelta (no children) */
36
+ function flatDelta(nodes: Array<{ id: string; value: string; position: string; parentId?: string }>): TreeDelta {
37
+ return {
38
+ roots: nodes
39
+ .filter((n) => n.parentId === undefined)
40
+ .map((root) => ({
41
+ id: root.id,
42
+ value: root.value,
43
+ position: root.position,
44
+ children: nodes
45
+ .filter((n) => n.parentId === root.id)
46
+ .map((child) => ({
47
+ id: child.id,
48
+ value: child.value,
49
+ position: child.position,
50
+ children: [],
51
+ })),
52
+ })),
53
+ };
54
+ }
55
+
56
+ describe("conflict visualization — move_reordered", () => {
57
+ it("fires when server places node at different position than local optimistic", () => {
58
+ const tree = makeTree();
59
+ const events: ConflictEvent[] = [];
60
+ tree.onConflict((e) => events.push(e));
61
+
62
+ // Local client adds node at position "a0"
63
+ const nodeId = tree.addNode(null, "a0", "Node");
64
+
65
+ // Server responds with the node at "b0" (concurrent move won)
66
+ tree.applyDelta(flatDelta([{ id: nodeId, value: "Node", position: "b0" }]));
67
+
68
+ expect(events).toHaveLength(1);
69
+ expect(events[0]!.kind).toBe("move_reordered");
70
+ if (events[0]!.kind === "move_reordered") {
71
+ expect(events[0]!.nodeId).toBe(nodeId);
72
+ expect(events[0]!.localPosition).toBe("a0");
73
+ expect(events[0]!.serverPosition).toBe("b0");
74
+ }
75
+ });
76
+
77
+ it("fires when server places node under different parent", () => {
78
+ const tree = makeTree();
79
+ const events: ConflictEvent[] = [];
80
+ tree.onConflict((e) => events.push(e));
81
+
82
+ const parentA = tree.addNode(null, "a0", "ParentA");
83
+ const parentB = tree.addNode(null, "b0", "ParentB");
84
+ const child = tree.addNode(parentA, "a0", "Child");
85
+
86
+ // Locally: child is under parentA. Server says it moved to parentB.
87
+ tree.applyDelta(
88
+ flatDelta([
89
+ { id: parentA, value: "ParentA", position: "a0" },
90
+ { id: parentB, value: "ParentB", position: "b0" },
91
+ { id: child, value: "Child", position: "a0", parentId: parentB },
92
+ ])
93
+ );
94
+
95
+ const moveEvents = events.filter((e) => e.kind === "move_reordered");
96
+ expect(moveEvents.length).toBeGreaterThanOrEqual(1);
97
+ const childEvent = moveEvents.find((e) => e.kind === "move_reordered" && e.nodeId === child);
98
+ expect(childEvent).toBeDefined();
99
+ if (childEvent?.kind === "move_reordered") {
100
+ expect(childEvent.localParentId).toBe(parentA);
101
+ expect(childEvent.serverParentId).toBe(parentB);
102
+ }
103
+ });
104
+
105
+ it("does not fire when server confirms local state", () => {
106
+ const tree = makeTree();
107
+ const events: ConflictEvent[] = [];
108
+ tree.onConflict((e) => events.push(e));
109
+
110
+ const nodeId = tree.addNode(null, "a0", "Node");
111
+
112
+ // Server agrees with local
113
+ tree.applyDelta(flatDelta([{ id: nodeId, value: "Node", position: "a0" }]));
114
+
115
+ expect(events).toHaveLength(0);
116
+ });
117
+ });
118
+
119
+ describe("conflict visualization — lww_overwrite", () => {
120
+ it("fires when server value differs from local value", () => {
121
+ const tree = makeTree();
122
+ const events: ConflictEvent[] = [];
123
+ tree.onConflict((e) => events.push(e));
124
+
125
+ const nodeId = tree.addNode(null, "a0", "LocalValue");
126
+
127
+ // Server resolves LWW in favor of a concurrent update
128
+ tree.applyDelta(flatDelta([{ id: nodeId, value: "RemoteWinner", position: "a0" }]));
129
+
130
+ const lwwEvents = events.filter((e) => e.kind === "lww_overwrite");
131
+ expect(lwwEvents).toHaveLength(1);
132
+ if (lwwEvents[0]!.kind === "lww_overwrite") {
133
+ expect(lwwEvents[0]!.discardedValue).toBe("LocalValue");
134
+ expect(lwwEvents[0]!.winnerValue).toBe("RemoteWinner");
135
+ }
136
+ });
137
+
138
+ it("does not fire when value matches local", () => {
139
+ const tree = makeTree();
140
+ const events: ConflictEvent[] = [];
141
+ tree.onConflict((e) => events.push(e));
142
+
143
+ const nodeId = tree.addNode(null, "a0", "Same");
144
+ tree.applyDelta(flatDelta([{ id: nodeId, value: "Same", position: "a0" }]));
145
+
146
+ expect(events.filter((e) => e.kind === "lww_overwrite")).toHaveLength(0);
147
+ });
148
+ });
149
+
150
+ describe("conflict visualization — move_discarded", () => {
151
+ it("fires when delta contains a discarded_move from server", () => {
152
+ const tree = makeTree();
153
+ const events: ConflictEvent[] = [];
154
+ tree.onConflict((e) => events.push(e));
155
+
156
+ const nodeId = tree.addNode(null, "a0", "Root");
157
+ const childId = tree.addNode(nodeId, "a0", "Child");
158
+
159
+ // Simulate server returning a delta with a discarded cycle move.
160
+ // The server rejected: move nodeId under childId (would be a cycle).
161
+ // Both nodeId parts: wall_ms:logical:node_id
162
+ const [wall, logical, nodeIdNum] = nodeId.split(":").map(Number) as [number, number, number];
163
+ const [cwall, clogical, cNodeIdNum] = childId.split(":").map(Number) as [number, number, number];
164
+
165
+ tree.applyDelta({
166
+ roots: [
167
+ {
168
+ id: nodeId,
169
+ value: "Root",
170
+ position: "a0",
171
+ children: [{ id: childId, value: "Child", position: "a0", children: [] }],
172
+ },
173
+ ],
174
+ discarded_moves: [
175
+ {
176
+ node_id: { wall_ms: wall, logical, node_id: nodeIdNum },
177
+ attempted_parent_id: { wall_ms: cwall, logical: clogical, node_id: cNodeIdNum },
178
+ attempted_position: "a0",
179
+ actual_parent_id: null,
180
+ actual_position: "a0",
181
+ },
182
+ ],
183
+ });
184
+
185
+ const discarded = events.filter((e) => e.kind === "move_discarded");
186
+ expect(discarded).toHaveLength(1);
187
+ if (discarded[0]!.kind === "move_discarded") {
188
+ expect(discarded[0]!.nodeId).toBe(nodeId);
189
+ expect(discarded[0]!.attemptedParentId).toBe(childId);
190
+ expect(discarded[0]!.attemptedPosition).toBe("a0");
191
+ expect(discarded[0]!.actualParentId).toBeNull();
192
+ }
193
+ });
194
+
195
+ it("does not fire when discarded_moves is empty", () => {
196
+ const tree = makeTree();
197
+ const events: ConflictEvent[] = [];
198
+ tree.onConflict((e) => events.push(e));
199
+
200
+ const nodeId = tree.addNode(null, "a0", "Root");
201
+ tree.applyDelta({
202
+ roots: [{ id: nodeId, value: "Root", position: "a0", children: [] }],
203
+ discarded_moves: [],
204
+ });
205
+
206
+ expect(events.filter((e) => e.kind === "move_discarded")).toHaveLength(0);
207
+ });
208
+ });
209
+
210
+ describe("conflict visualization — conflictLog()", () => {
211
+ it("accumulates events in chronological order", () => {
212
+ const tree = makeTree();
213
+ const n1 = tree.addNode(null, "a0", "V1");
214
+ const n2 = tree.addNode(null, "b0", "V2");
215
+
216
+ tree.applyDelta(flatDelta([
217
+ { id: n1, value: "X", position: "a0" }, // lww_overwrite
218
+ { id: n2, value: "V2", position: "c0" }, // move_reordered
219
+ ]));
220
+
221
+ const log = tree.conflictLog();
222
+ expect(log.length).toBeGreaterThanOrEqual(2);
223
+ expect(log.map((e) => e.kind)).toContain("lww_overwrite");
224
+ expect(log.map((e) => e.kind)).toContain("move_reordered");
225
+ });
226
+
227
+ it("onConflict unsubscribe works", () => {
228
+ const tree = makeTree();
229
+ const events: ConflictEvent[] = [];
230
+ const unsub = tree.onConflict((e) => events.push(e));
231
+
232
+ const nodeId = tree.addNode(null, "a0", "A");
233
+ unsub();
234
+
235
+ tree.applyDelta(flatDelta([{ id: nodeId, value: "B", position: "z9" }]));
236
+
237
+ // Listener was removed — no events delivered
238
+ expect(events).toHaveLength(0);
239
+ // But log still captures it
240
+ expect(tree.conflictLog().length).toBeGreaterThan(0);
241
+ });
242
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { fi, between, before, after, start, end, spread } from "../src/utils/fractional.js";
3
+
4
+ describe("fractional indexing — between()", () => {
5
+ it("returns a string strictly between two positions", () => {
6
+ const mid = between("a0", "z9");
7
+ expect(mid > "a0").toBe(true);
8
+ expect(mid < "z9").toBe(true);
9
+ });
10
+
11
+ it("works for adjacent single-char positions", () => {
12
+ const mid = between("a0", "b0");
13
+ expect(mid > "a0").toBe(true);
14
+ expect(mid < "b0").toBe(true);
15
+ });
16
+
17
+ it("handles equal-length strings", () => {
18
+ const mid = between("a0", "a2");
19
+ expect(mid > "a0").toBe(true);
20
+ expect(mid < "a2").toBe(true);
21
+ });
22
+
23
+ it("handles different-length strings", () => {
24
+ const mid = between("a", "b0");
25
+ expect(mid > "a").toBe(true);
26
+ expect(mid < "b0").toBe(true);
27
+ });
28
+
29
+ it("subdivides when gap is minimal", () => {
30
+ // "a0" and "a1" are adjacent — between should append a char
31
+ const mid = between("a0", "a1");
32
+ expect(mid > "a0").toBe(true);
33
+ expect(mid < "a1").toBe(true);
34
+ });
35
+
36
+ it("throws when a >= b", () => {
37
+ expect(() => between("b0", "a0")).toThrow();
38
+ expect(() => between("a0", "a0")).toThrow();
39
+ });
40
+
41
+ it("can be called recursively to produce ordered sequence", () => {
42
+ const p1 = between("a0", "z9");
43
+ const p2 = between(p1, "z9");
44
+ const p3 = between(p1, p2);
45
+ expect("a0" < p1).toBe(true);
46
+ expect(p1 < p2).toBe(true);
47
+ expect(p1 < p3).toBe(true);
48
+ expect(p3 < p2).toBe(true);
49
+ expect(p2 < "z9").toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("fractional indexing — before() / after()", () => {
54
+ it("before returns a string less than the input", () => {
55
+ const p = before("a0");
56
+ expect(p < "a0").toBe(true);
57
+ });
58
+
59
+ it("after returns a string greater than the input", () => {
60
+ const p = after("z9");
61
+ expect(p > "z9").toBe(true);
62
+ });
63
+
64
+ it("before/after round-trips are monotone", () => {
65
+ const base = "m4";
66
+ const b = before(base);
67
+ const a = after(base);
68
+ expect(b < base).toBe(true);
69
+ expect(a > base).toBe(true);
70
+ expect(b < a).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("fractional indexing — start() / end()", () => {
75
+ it("start < end", () => {
76
+ expect(start() < end()).toBe(true);
77
+ });
78
+
79
+ it("between(start, end) is valid", () => {
80
+ const mid = between(start(), end());
81
+ expect(mid > start()).toBe(true);
82
+ expect(mid < end()).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe("fractional indexing — spread()", () => {
87
+ it("returns n positions in ascending order", () => {
88
+ const positions = spread("a0", "z9", 4);
89
+ expect(positions).toHaveLength(4);
90
+ for (let i = 1; i < positions.length; i++) {
91
+ expect(positions[i]! > positions[i - 1]!).toBe(true);
92
+ }
93
+ });
94
+
95
+ it("all positions are within bounds", () => {
96
+ const positions = spread("a0", "z9", 5);
97
+ for (const p of positions) {
98
+ expect(p > "a0").toBe(true);
99
+ expect(p < "z9").toBe(true);
100
+ }
101
+ });
102
+
103
+ it("returns empty array for n=0", () => {
104
+ expect(spread("a0", "z9", 0)).toEqual([]);
105
+ });
106
+
107
+ it("returns single midpoint for n=1", () => {
108
+ const [p] = spread("a0", "z9", 1);
109
+ expect(p! > "a0").toBe(true);
110
+ expect(p! < "z9").toBe(true);
111
+ });
112
+ });
113
+
114
+ describe("fractional indexing — fi namespace", () => {
115
+ it("fi object exposes all helpers", () => {
116
+ expect(typeof fi.between).toBe("function");
117
+ expect(typeof fi.before).toBe("function");
118
+ expect(typeof fi.after).toBe("function");
119
+ expect(typeof fi.start).toBe("function");
120
+ expect(typeof fi.end).toBe("function");
121
+ expect(typeof fi.spread).toBe("function");
122
+ });
123
+
124
+ it("fi.between matches named export", () => {
125
+ expect(fi.between("a0", "z9")).toBe(between("a0", "z9"));
126
+ });
127
+ });