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.
Files changed (67) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -0
  3. package/dist/agents.d.ts +143 -8
  4. package/dist/agents.d.ts.map +1 -1
  5. package/dist/agents.js +141 -14
  6. package/dist/agents.js.map +1 -1
  7. package/dist/client.d.ts +35 -2
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +45 -3
  10. package/dist/client.js.map +1 -1
  11. package/dist/conflict/types.d.ts +54 -0
  12. package/dist/conflict/types.d.ts.map +1 -0
  13. package/dist/conflict/types.js +9 -0
  14. package/dist/conflict/types.js.map +1 -0
  15. package/dist/crdt/rga.d.ts +20 -4
  16. package/dist/crdt/rga.d.ts.map +1 -1
  17. package/dist/crdt/rga.js +42 -6
  18. package/dist/crdt/rga.js.map +1 -1
  19. package/dist/crdt/tree.d.ts +128 -0
  20. package/dist/crdt/tree.d.ts.map +1 -0
  21. package/dist/crdt/tree.js +304 -0
  22. package/dist/crdt/tree.js.map +1 -0
  23. package/dist/index.d.ts +13 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +10 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/schema.d.ts +63 -3
  28. package/dist/schema.d.ts.map +1 -1
  29. package/dist/schema.js +18 -1
  30. package/dist/schema.js.map +1 -1
  31. package/dist/sync/delta.d.ts +35 -0
  32. package/dist/sync/delta.d.ts.map +1 -1
  33. package/dist/sync/delta.js +4 -0
  34. package/dist/sync/delta.js.map +1 -1
  35. package/dist/undo/UndoManager.d.ts +74 -0
  36. package/dist/undo/UndoManager.d.ts.map +1 -0
  37. package/dist/undo/UndoManager.js +318 -0
  38. package/dist/undo/UndoManager.js.map +1 -0
  39. package/dist/undo/types.d.ts +63 -0
  40. package/dist/undo/types.d.ts.map +1 -0
  41. package/dist/undo/types.js +9 -0
  42. package/dist/undo/types.js.map +1 -0
  43. package/dist/utils/fractional.d.ts +78 -0
  44. package/dist/utils/fractional.d.ts.map +1 -0
  45. package/dist/utils/fractional.js +159 -0
  46. package/dist/utils/fractional.js.map +1 -0
  47. package/dist/validation/index.d.ts +107 -0
  48. package/dist/validation/index.d.ts.map +1 -0
  49. package/dist/validation/index.js +123 -0
  50. package/dist/validation/index.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/agents.ts +224 -15
  53. package/src/client.ts +52 -3
  54. package/src/conflict/types.ts +60 -0
  55. package/src/crdt/rga.ts +46 -7
  56. package/src/crdt/tree.ts +367 -0
  57. package/src/index.ts +31 -1
  58. package/src/schema.ts +24 -1
  59. package/src/sync/delta.ts +30 -1
  60. package/src/undo/UndoManager.ts +369 -0
  61. package/src/undo/types.ts +74 -0
  62. package/src/utils/fractional.ts +166 -0
  63. package/src/validation/index.ts +137 -0
  64. package/test/conflict.test.ts +242 -0
  65. package/test/fractional.test.ts +127 -0
  66. package/test/undo.test.ts +272 -0
  67. package/test/validation.test.ts +137 -0
package/src/agents.ts CHANGED
@@ -1,18 +1,41 @@
1
1
  /**
2
- * Meridian × Anthropic Claude tool use helpers.
2
+ * Meridian AI agent tool use helpers.
3
3
  *
4
- * Generates Anthropic-compatible `Tool` definitions from a list of CRDT IDs
4
+ * Generates provider-compatible tool definitions from a list of CRDT IDs
5
5
  * and executes tool calls against a Meridian server via HTTP (no WebSocket).
6
6
  *
7
- * Usage:
7
+ * Supported providers:
8
+ * - Anthropic Claude — `getMeridianTools()` (default)
9
+ * - OpenAI GPT — `toOpenAITools(getMeridianTools(...))`
10
+ * - Google Gemini — `toGeminiTools(getMeridianTools(...))`
11
+ *
12
+ * Usage (Anthropic):
8
13
  * ```ts
9
14
  * import { getMeridianTools, executeMeridianTool } from "meridian-sdk";
10
15
  *
11
- * const tools = getMeridianTools({ baseUrl, token, namespace, crdtIds: ["counter", "tasks"] });
16
+ * const tools = getMeridianTools(config, ["counter", "tasks"]);
17
+ * // Pass to Anthropic messages.create({ tools })
18
+ * const result = await executeMeridianTool(config, toolUseBlock);
19
+ * ```
20
+ *
21
+ * Usage (OpenAI):
22
+ * ```ts
23
+ * import { getMeridianTools, toOpenAITools, executeOpenAITool } from "meridian-sdk";
24
+ *
25
+ * const tools = toOpenAITools(getMeridianTools(config, ["counter", "tasks"]));
26
+ * // Pass to openai.chat.completions.create({ tools })
27
+ * // For each tool_call in the response — pass directly, no wrapping:
28
+ * const result = await executeOpenAITool(config, call);
29
+ * ```
30
+ *
31
+ * Usage (Gemini):
32
+ * ```ts
33
+ * import { getMeridianTools, toGeminiTools, executeGeminiTool } from "meridian-sdk";
12
34
  *
13
- * // Pass tools to Anthropic messages.create(...)
14
- * // Then for each tool_use block in the response:
15
- * const result = await executeMeridianTool({ baseUrl, token, namespace }, toolUseBlock);
35
+ * const tools = toGeminiTools(getMeridianTools(config, ["counter", "tasks"]));
36
+ * // Pass to model.generateContent({ tools })
37
+ * // For each functionCall part pass directly, no wrapping:
38
+ * const result = await executeGeminiTool(config, part.functionCall);
16
39
  * ```
17
40
  */
18
41
 
@@ -28,6 +51,94 @@ export interface Tool {
28
51
  };
29
52
  }
30
53
 
54
+ // ---------------------------------------------------------------------------
55
+ // OpenAI adapter
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** OpenAI-compatible tool definition (Chat Completions `tools` array entry). */
59
+ export interface OpenAITool {
60
+ type: "function";
61
+ function: {
62
+ name: string;
63
+ description: string;
64
+ parameters: {
65
+ type: "object";
66
+ properties: Record<string, { type: string; description: string }>;
67
+ required?: string[];
68
+ };
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Convert Meridian tools to OpenAI Chat Completions format.
74
+ *
75
+ * ```ts
76
+ * const tools = toOpenAITools(getMeridianTools(config, crdtIds));
77
+ * // openai.chat.completions.create({ tools })
78
+ * ```
79
+ */
80
+ export function toOpenAITools(tools: Tool[]): OpenAITool[] {
81
+ return tools.map(t => ({
82
+ type: "function",
83
+ function: {
84
+ name: t.name,
85
+ description: t.description,
86
+ parameters: {
87
+ type: "object",
88
+ properties: t.input_schema.properties,
89
+ ...(t.input_schema.required ? { required: t.input_schema.required } : {}),
90
+ },
91
+ },
92
+ }));
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Gemini adapter
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Gemini FunctionDeclaration (single entry inside a `Tool.functionDeclarations` array). */
100
+ export interface GeminiFunctionDeclaration {
101
+ name: string;
102
+ description: string;
103
+ parameters: {
104
+ type: "OBJECT";
105
+ properties: Record<string, { type: string; description: string }>;
106
+ required?: string[];
107
+ };
108
+ }
109
+
110
+ /** Gemini `Tool` wrapper passed to `model.generateContent({ tools })`. */
111
+ export interface GeminiTool {
112
+ functionDeclarations: GeminiFunctionDeclaration[];
113
+ }
114
+
115
+ /**
116
+ * Convert Meridian tools to Google Gemini format.
117
+ *
118
+ * ```ts
119
+ * const tools = toGeminiTools(getMeridianTools(config, crdtIds));
120
+ * // model.generateContent({ tools })
121
+ * ```
122
+ */
123
+ export function toGeminiTools(tools: Tool[]): GeminiTool {
124
+ return {
125
+ functionDeclarations: tools.map(t => ({
126
+ name: t.name,
127
+ description: t.description,
128
+ parameters: {
129
+ type: "OBJECT",
130
+ properties: t.input_schema.properties,
131
+ ...(t.input_schema.required ? { required: t.input_schema.required } : {}),
132
+ },
133
+ })),
134
+ };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Provider-specific input types (structural matches — no provider SDK deps)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** Anthropic tool_use block — matches `ContentBlock` from `@anthropic-ai/sdk`. */
31
142
  export interface ToolUseBlock {
32
143
  type: "tool_use";
33
144
  id: string;
@@ -35,6 +146,29 @@ export interface ToolUseBlock {
35
146
  input: Record<string, unknown>;
36
147
  }
37
148
 
149
+ /**
150
+ * OpenAI tool call — structurally matches `ChatCompletionMessageToolCall`
151
+ * from the `openai` package. Pass directly from `response.choices[0].message.tool_calls`.
152
+ */
153
+ export interface OpenAIToolCall {
154
+ id: string;
155
+ type: "function";
156
+ function: {
157
+ name: string;
158
+ /** JSON-encoded arguments string, as returned by the OpenAI API. */
159
+ arguments: string;
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Gemini function call — structurally matches `FunctionCall` from
165
+ * `@google/generative-ai`. Pass directly from `part.functionCall`.
166
+ */
167
+ export interface GeminiFunctionCall {
168
+ name: string;
169
+ args: Record<string, unknown>;
170
+ }
171
+
38
172
  export interface MeridianAgentConfig {
39
173
  /** HTTP base URL of the Meridian server (e.g. https://meridian.example.com) */
40
174
  baseUrl: string;
@@ -114,19 +248,21 @@ export function getMeridianTools(
114
248
  return tools;
115
249
  }
116
250
 
251
+ // ---------------------------------------------------------------------------
252
+ // Shared dispatcher (private)
253
+ // ---------------------------------------------------------------------------
254
+
117
255
  /**
118
- * Execute a Meridian tool call returned by Claude.
119
- *
120
- * Returns a JSON string suitable for the `content` field of a `tool_result` block.
256
+ * Core dispatch logic — routes a (name, input) pair to the right HTTP operation.
257
+ * All provider-specific executors delegate here.
121
258
  */
122
- export async function executeMeridianTool(
259
+ async function dispatchTool(
123
260
  config: MeridianAgentConfig,
124
- toolUse: ToolUseBlock,
261
+ name: string,
262
+ input: Record<string, unknown>,
125
263
  ): Promise<string> {
126
264
  const { baseUrl, token, namespace } = config;
127
- const { name, input } = toolUse;
128
265
 
129
- // Detect operation type and crdt_id from tool name
130
266
  const readMatch = name.match(/^meridian_read_(.+)$/);
131
267
  const incrementMatch = name.match(/^meridian_increment_(.+)$/);
132
268
  const setMatch = name.match(/^meridian_set_(.+)$/);
@@ -156,7 +292,7 @@ export async function executeMeridianTool(
156
292
  if (addMatch?.[1]) {
157
293
  const crdtId = addMatch[1].replace(/_/g, "-");
158
294
  // Always store as a string so useORSet<string> can read it back directly.
159
- // If Claude passes a JSON object, re-serialize it to a stable string.
295
+ // If the model passes a JSON object, re-serialize it to a stable string.
160
296
  const rawEl = input.element;
161
297
  const element: string = typeof rawEl === "string" ? rawEl : JSON.stringify(rawEl ?? null);
162
298
  const clientId = Number(input.client_id ?? 1);
@@ -166,6 +302,79 @@ export async function executeMeridianTool(
166
302
  return JSON.stringify({ error: `Unknown tool: ${name}` });
167
303
  }
168
304
 
305
+ // ---------------------------------------------------------------------------
306
+ // Provider executors
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /**
310
+ * Execute a Meridian tool call returned by **Anthropic Claude**.
311
+ *
312
+ * Returns a JSON string suitable for the `content` field of a `tool_result` block.
313
+ *
314
+ * ```ts
315
+ * for (const block of response.content) {
316
+ * if (block.type === "tool_use") {
317
+ * const result = await executeMeridianTool(config, block);
318
+ * toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
319
+ * }
320
+ * }
321
+ * ```
322
+ */
323
+ export async function executeMeridianTool(
324
+ config: MeridianAgentConfig,
325
+ toolUse: ToolUseBlock,
326
+ ): Promise<string> {
327
+ return dispatchTool(config, toolUse.name, toolUse.input);
328
+ }
329
+
330
+ /**
331
+ * Execute a Meridian tool call returned by **OpenAI**.
332
+ *
333
+ * Pass `tool_call` directly from `response.choices[0].message.tool_calls` —
334
+ * no wrapping required. Returns a JSON string for the `tool` role message content.
335
+ *
336
+ * ```ts
337
+ * for (const call of response.choices[0].message.tool_calls ?? []) {
338
+ * const result = await executeOpenAITool(config, call);
339
+ * messages.push({ role: "tool", tool_call_id: call.id, content: result });
340
+ * }
341
+ * ```
342
+ */
343
+ export async function executeOpenAITool(
344
+ config: MeridianAgentConfig,
345
+ toolCall: OpenAIToolCall,
346
+ ): Promise<string> {
347
+ let input: Record<string, unknown>;
348
+ try {
349
+ input = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
350
+ } catch {
351
+ return JSON.stringify({ error: "Failed to parse tool call arguments" });
352
+ }
353
+ return dispatchTool(config, toolCall.function.name, input);
354
+ }
355
+
356
+ /**
357
+ * Execute a Meridian tool call returned by **Google Gemini**.
358
+ *
359
+ * Pass `part.functionCall` directly — no wrapping, no id hack required.
360
+ * Returns a JSON string for the `functionResponse` part content.
361
+ *
362
+ * ```ts
363
+ * for (const part of response.candidates?.[0].content.parts ?? []) {
364
+ * if (part.functionCall) {
365
+ * const result = await executeGeminiTool(config, part.functionCall);
366
+ * // pass result back as functionResponse part
367
+ * }
368
+ * }
369
+ * ```
370
+ */
371
+ export async function executeGeminiTool(
372
+ config: MeridianAgentConfig,
373
+ functionCall: GeminiFunctionCall,
374
+ ): Promise<string> {
375
+ return dispatchTool(config, functionCall.name, functionCall.args);
376
+ }
377
+
169
378
  async function readCrdt(
170
379
  baseUrl: string,
171
380
  token: string,
package/src/client.ts CHANGED
@@ -11,6 +11,8 @@ import { PresenceHandle } from "./crdt/presence.js";
11
11
  import { CRDTMapHandle } from "./crdt/crdtmap.js";
12
12
  import { AwarenessHandle } from "./crdt/awareness.js";
13
13
  import { RGAHandle } from "./crdt/rga.js";
14
+ import { TreeHandle } from "./crdt/tree.js";
15
+ import type { CrdtValidator } from "./validation/index.js";
14
16
  import {
15
17
  decodeGCounterDelta,
16
18
  decodePNCounterDelta,
@@ -19,7 +21,9 @@ import {
19
21
  decodePresenceDelta,
20
22
  decodeCRDTMapDelta,
21
23
  decodeRGADelta,
24
+ decodeTreeDelta,
22
25
  } from "./sync/delta.js";
26
+ import type { TreeNodeValue } from "./sync/delta.js";
23
27
  import { parseAndValidateToken } from "./auth/token.js";
24
28
  import type { ServerMsg, TokenClaims } from "./schema.js";
25
29
  import type { TokenParseError, TokenExpiredError } from "./errors.js";
@@ -63,6 +67,11 @@ export interface RGASnapshotEntry {
63
67
  crdtId: string;
64
68
  text: string;
65
69
  }
70
+ export interface TreeSnapshotEntry {
71
+ type: "tree";
72
+ crdtId: string;
73
+ roots: TreeNodeValue[];
74
+ }
66
75
  export type CRDTSnapshotEntry =
67
76
  | GCounterSnapshotEntry
68
77
  | PNCounterSnapshotEntry
@@ -70,7 +79,8 @@ export type CRDTSnapshotEntry =
70
79
  | LwwRegisterSnapshotEntry
71
80
  | PresenceSnapshotEntry
72
81
  | CRDTMapSnapshotEntry
73
- | RGASnapshotEntry;
82
+ | RGASnapshotEntry
83
+ | TreeSnapshotEntry;
74
84
 
75
85
  export interface DeltaEvent {
76
86
  crdtId: string;
@@ -136,6 +146,7 @@ export class MeridianClient {
136
146
  private readonly cmHandles = new Map<string, CRDTMapHandle>();
137
147
  private readonly awHandles = new Map<string, AwarenessHandle<unknown>>();
138
148
  private readonly rgaHandles = new Map<string, RGAHandle>();
149
+ private readonly treeHandles = new Map<string, TreeHandle>();
139
150
 
140
151
  private readonly anyListeners = new Set<() => void>();
141
152
  private readonly deltaListeners = new Set<(event: DeltaEvent) => void>();
@@ -382,10 +393,10 @@ export class MeridianClient {
382
393
  * doc.onChange(text => console.log(text));
383
394
  * ```
384
395
  */
385
- rga(crdtId: string): RGAHandle {
396
+ rga(crdtId: string, opts?: { validator?: CrdtValidator }): RGAHandle {
386
397
  let handle = this.rgaHandles.get(crdtId);
387
398
  if (!handle) {
388
- handle = new RGAHandle({ crdtId, clientId: this.clientId, transport: this.transport });
399
+ handle = new RGAHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined && { validator: opts.validator }) });
389
400
  this.rgaHandles.set(crdtId, handle);
390
401
  this.transport.subscribe(crdtId);
391
402
  this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
@@ -393,6 +404,36 @@ export class MeridianClient {
393
404
  return handle;
394
405
  }
395
406
 
407
+ /**
408
+ * Returns a handle for a TreeCRDT — a convergent hierarchical tree.
409
+ *
410
+ * Use this for outlines, document trees, mind maps, or any nested structure
411
+ * that needs concurrent editing. Concurrent moves use Kleppmann (2021)
412
+ * move semantics: cycle-creating moves are discarded, all replicas converge.
413
+ *
414
+ * Handles are cached by `crdtId`; the same handle is returned for repeated calls.
415
+ *
416
+ * @param crdtId - Logical CRDT identifier (e.g. `"tree:outline"`).
417
+ *
418
+ * @example
419
+ * ```ts
420
+ * const tree = client.tree('doc:outline');
421
+ * const rootId = tree.addNode(null, 'a0', 'Introduction');
422
+ * const childId = tree.addNode(rootId, 'a0', 'Chapter 1');
423
+ * tree.onChange(t => console.log(t.roots));
424
+ * ```
425
+ */
426
+ tree(crdtId: string, opts?: { validator?: CrdtValidator }): TreeHandle {
427
+ let handle = this.treeHandles.get(crdtId);
428
+ if (!handle) {
429
+ handle = new TreeHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined && { validator: opts.validator }) });
430
+ this.treeHandles.set(crdtId, handle);
431
+ this.transport.subscribe(crdtId);
432
+ this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
433
+ }
434
+ return handle;
435
+ }
436
+
396
437
  /**
397
438
  * Returns a handle for an ephemeral awareness channel.
398
439
  *
@@ -487,6 +528,8 @@ export class MeridianClient {
487
528
  crdts.push({ type: "crdtmap", crdtId, value: h.value() });
488
529
  for (const [crdtId, h] of this.rgaHandles)
489
530
  crdts.push({ type: "rga", crdtId, text: h.value() });
531
+ for (const [crdtId, h] of this.treeHandles)
532
+ crdts.push({ type: "tree", crdtId, roots: h.value().roots });
490
533
  return {
491
534
  namespace: this.namespace,
492
535
  clientId: this.clientId,
@@ -570,6 +613,12 @@ export class MeridianClient {
570
613
  if (rgaHandle) {
571
614
  try { rgaHandle.applyDelta(decodeRGADelta(delta_bytes)); } catch { /* stale */ }
572
615
  this.notifyDelta(crdt_id, "rga");
616
+ return;
617
+ }
618
+ const treeHandle = this.treeHandles.get(crdt_id);
619
+ if (treeHandle) {
620
+ try { treeHandle.applyDelta(decodeTreeDelta(delta_bytes)); } catch { /* stale */ }
621
+ this.notifyDelta(crdt_id, "tree");
573
622
  }
574
623
  }
575
624
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Conflict events emitted by TreeHandle when the server delta reveals a
3
+ * resolution that differs from what the local client expected.
4
+ *
5
+ * These are purely informational — the CRDT has already converged. Use them
6
+ * for audit logs, "track changes" UIs, or debugging concurrent edit scenarios.
7
+ */
8
+
9
+ /**
10
+ * A concurrent MoveNode op caused a cycle (e.g. moving a node under its own
11
+ * descendant). The server silently discarded the move; the node stayed in
12
+ * place. This event tells you which move was dropped and where it tried to go.
13
+ */
14
+ export interface MoveDiscardedEvent {
15
+ readonly kind: "move_discarded";
16
+ /** The node that was supposed to move. */
17
+ readonly nodeId: string;
18
+ /** Where the move tried to put it (null = root level). */
19
+ readonly attemptedParentId: string | null;
20
+ readonly attemptedPosition: string;
21
+ /** Where the node actually stayed. */
22
+ readonly actualParentId: string | null;
23
+ readonly actualPosition: string;
24
+ readonly at: number; // wall clock ms
25
+ }
26
+
27
+ /**
28
+ * Two concurrent MoveNode ops targeted the same node. The server picked a
29
+ * winner (highest HLC op_id); the loser's destination was overridden.
30
+ */
31
+ export interface MoveReorderedEvent {
32
+ readonly kind: "move_reordered";
33
+ readonly nodeId: string;
34
+ /** Where the local client moved it (optimistic). */
35
+ readonly localParentId: string | null;
36
+ readonly localPosition: string;
37
+ /** Where the server placed it after conflict resolution. */
38
+ readonly serverParentId: string | null;
39
+ readonly serverPosition: string;
40
+ readonly at: number;
41
+ }
42
+
43
+ /**
44
+ * Two concurrent UpdateNode ops targeted the same node. LWW resolved it;
45
+ * the value with the older `updated_at` HLC was discarded.
46
+ */
47
+ export interface LwwOverwriteEvent {
48
+ readonly kind: "lww_overwrite";
49
+ readonly nodeId: string;
50
+ /** The value that was overwritten (loser). */
51
+ readonly discardedValue: string;
52
+ /** The value that won. */
53
+ readonly winnerValue: string;
54
+ readonly at: number;
55
+ }
56
+
57
+ export type ConflictEvent =
58
+ | MoveDiscardedEvent
59
+ | MoveReorderedEvent
60
+ | LwwOverwriteEvent;
package/src/crdt/rga.ts CHANGED
@@ -2,6 +2,7 @@ import { Chunk, Effect, Stream } from "effect";
2
2
  import { encode } from "../codec.js";
3
3
  import type { WsTransport } from "../transport/websocket.js";
4
4
  import type { RGADelta } from "../sync/delta.js";
5
+ import { type CrdtValidator, runValidator } from "../validation/index.js";
5
6
 
6
7
  /**
7
8
  * Low-level handle for an RGA (Replicated Growable Array) CRDT — collaborative text editing.
@@ -16,18 +17,24 @@ export class RGAHandle {
16
17
  private readonly crdtId: string;
17
18
  private readonly clientId: number;
18
19
  private readonly transport: WsTransport;
20
+ private readonly validator: CrdtValidator | undefined;
19
21
  private readonly listeners = new Set<(value: string) => void>();
20
22
 
21
23
  constructor(opts: {
22
24
  crdtId: string;
23
25
  clientId: number;
24
26
  transport: WsTransport;
27
+ validator?: CrdtValidator;
25
28
  }) {
26
29
  this.crdtId = opts.crdtId;
27
30
  this.clientId = opts.clientId;
28
31
  this.transport = opts.transport;
32
+ this.validator = opts.validator;
29
33
  }
30
34
 
35
+ /** The CRDT key this handle is bound to. */
36
+ get id(): string { return this.crdtId; }
37
+
31
38
  /** Returns the current text content. */
32
39
  value(): string {
33
40
  return this.text;
@@ -53,14 +60,17 @@ export class RGAHandle {
53
60
 
54
61
  /**
55
62
  * Inserts `text` at visible position `pos` (0 = before all characters).
56
- * Characters are inserted one by one in order using the RGA Insert op.
63
+ * Characters are inserted one by one using RGA Insert ops.
57
64
  *
58
- * @param pos - Visible character position (0-indexed).
59
- * @param text - String to insert.
65
+ * @param pos - Visible character position (0-indexed).
66
+ * @param text - String to insert.
60
67
  * @param ttlMs - Optional TTL for the op.
68
+ * @returns The HLC strings ("wall_ms:logical:node_id") of the inserted nodes,
69
+ * in insertion order. Used by UndoManager to record undo entries.
61
70
  */
62
- insert(pos: number, text: string, ttlMs?: number): void {
63
- if (text.length === 0) return;
71
+ insert(pos: number, text: string, ttlMs?: number): string[] {
72
+ if (text.length === 0) return [];
73
+ runValidator(this.validator, text);
64
74
 
65
75
  // Optimistic local update.
66
76
  this.text = this.text.slice(0, pos) + text + this.text.slice(pos);
@@ -69,12 +79,15 @@ export class RGAHandle {
69
79
  // Send each character as a separate RGA Insert op. The server reorders
70
80
  // them using their HLC IDs — sending individually preserves per-char identity.
71
81
  const wallMs = Date.now();
82
+ const nodeIds: string[] = [];
72
83
  for (let i = 0; i < text.length; i++) {
84
+ const id = { wall_ms: wallMs, logical: i, node_id: this.clientId };
85
+ nodeIds.push(`${wallMs}:${i}:${this.clientId}`);
73
86
  const op = encode({
74
87
  RGA: {
75
88
  Insert: {
76
- id: { wall_ms: wallMs, logical: i, node_id: this.clientId },
77
- origin_id: pos + i === 0 ? null : null, // server resolves via WAL
89
+ id,
90
+ origin_id: null, // server resolves via WAL
78
91
  content: text[i],
79
92
  },
80
93
  },
@@ -87,6 +100,32 @@ export class RGAHandle {
87
100
  },
88
101
  });
89
102
  }
103
+ return nodeIds;
104
+ }
105
+
106
+ /**
107
+ * Deletes the RGA node with the given HLC string ID directly.
108
+ * Used by UndoManager to undo an insert by its exact node identity,
109
+ * regardless of the current visible position.
110
+ *
111
+ * @param hlcString - HLC string "wall_ms:logical:node_id" returned by insert().
112
+ * @param ttlMs - Optional TTL.
113
+ */
114
+ deleteById(hlcString: string, ttlMs?: number): void {
115
+ const parts = hlcString.split(":");
116
+ const id = {
117
+ wall_ms: Number(parts[0]),
118
+ logical: Number(parts[1]),
119
+ node_id: Number(parts[2]),
120
+ };
121
+ const op = encode({ RGA: { Delete: { id } } });
122
+ this.transport.send({
123
+ Op: {
124
+ crdt_id: this.crdtId,
125
+ op_bytes: op,
126
+ ...(ttlMs !== undefined && { ttl_ms: ttlMs }),
127
+ },
128
+ });
90
129
  }
91
130
 
92
131
  /**