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.
- package/CHANGELOG.md +10 -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 +7 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- 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 +45 -0
- package/dist/crdt/tree.d.ts.map +1 -1
- package/dist/crdt/tree.js +135 -10
- package/dist/crdt/tree.js.map +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -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 +24 -0
- package/dist/sync/delta.d.ts.map +1 -1
- package/dist/sync/delta.js +1 -1
- 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 +5 -4
- package/src/conflict/types.ts +60 -0
- package/src/crdt/rga.ts +46 -7
- package/src/crdt/tree.ts +164 -0
- package/src/index.ts +28 -1
- package/src/schema.ts +24 -1
- package/src/sync/delta.ts +15 -2
- 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
package/src/agents.ts
CHANGED
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Meridian
|
|
2
|
+
* Meridian AI agent tool use helpers.
|
|
3
3
|
*
|
|
4
|
-
* Generates
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
14
|
-
* //
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
259
|
+
async function dispatchTool(
|
|
123
260
|
config: MeridianAgentConfig,
|
|
124
|
-
|
|
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
|
|
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
|
@@ -12,6 +12,7 @@ import { CRDTMapHandle } from "./crdt/crdtmap.js";
|
|
|
12
12
|
import { AwarenessHandle } from "./crdt/awareness.js";
|
|
13
13
|
import { RGAHandle } from "./crdt/rga.js";
|
|
14
14
|
import { TreeHandle } from "./crdt/tree.js";
|
|
15
|
+
import type { CrdtValidator } from "./validation/index.js";
|
|
15
16
|
import {
|
|
16
17
|
decodeGCounterDelta,
|
|
17
18
|
decodePNCounterDelta,
|
|
@@ -392,10 +393,10 @@ export class MeridianClient {
|
|
|
392
393
|
* doc.onChange(text => console.log(text));
|
|
393
394
|
* ```
|
|
394
395
|
*/
|
|
395
|
-
rga(crdtId: string): RGAHandle {
|
|
396
|
+
rga(crdtId: string, opts?: { validator?: CrdtValidator }): RGAHandle {
|
|
396
397
|
let handle = this.rgaHandles.get(crdtId);
|
|
397
398
|
if (!handle) {
|
|
398
|
-
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 }) });
|
|
399
400
|
this.rgaHandles.set(crdtId, handle);
|
|
400
401
|
this.transport.subscribe(crdtId);
|
|
401
402
|
this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
|
|
@@ -422,10 +423,10 @@ export class MeridianClient {
|
|
|
422
423
|
* tree.onChange(t => console.log(t.roots));
|
|
423
424
|
* ```
|
|
424
425
|
*/
|
|
425
|
-
tree(crdtId: string): TreeHandle {
|
|
426
|
+
tree(crdtId: string, opts?: { validator?: CrdtValidator }): TreeHandle {
|
|
426
427
|
let handle = this.treeHandles.get(crdtId);
|
|
427
428
|
if (!handle) {
|
|
428
|
-
handle = new TreeHandle({ crdtId, clientId: this.clientId, transport: this.transport });
|
|
429
|
+
handle = new TreeHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined && { validator: opts.validator }) });
|
|
429
430
|
this.treeHandles.set(crdtId, handle);
|
|
430
431
|
this.transport.subscribe(crdtId);
|
|
431
432
|
this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
|
|
@@ -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
|
|
63
|
+
* Characters are inserted one by one using RGA Insert ops.
|
|
57
64
|
*
|
|
58
|
-
* @param pos
|
|
59
|
-
* @param text
|
|
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):
|
|
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
|
|
77
|
-
origin_id:
|
|
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
|
/**
|