meridian-sdk 1.0.1 → 1.2.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 +14 -0
- package/README.md +48 -0
- package/dist/agents.d.ts +60 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +200 -0
- package/dist/agents.js.map +1 -0
- package/dist/client.d.ts +30 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +43 -1
- package/dist/client.js.map +1 -1
- package/dist/crdt/awareness.d.ts +3 -0
- package/dist/crdt/awareness.d.ts.map +1 -1
- package/dist/crdt/awareness.js +8 -4
- package/dist/crdt/awareness.js.map +1 -1
- package/dist/crdt/crdtmap.d.ts +3 -0
- package/dist/crdt/crdtmap.d.ts.map +1 -1
- package/dist/crdt/crdtmap.js +8 -0
- package/dist/crdt/crdtmap.js.map +1 -1
- package/dist/crdt/gcounter.d.ts +3 -0
- package/dist/crdt/gcounter.d.ts.map +1 -1
- package/dist/crdt/gcounter.js +8 -0
- package/dist/crdt/gcounter.js.map +1 -1
- package/dist/crdt/lwwregister.d.ts +3 -1
- package/dist/crdt/lwwregister.d.ts.map +1 -1
- package/dist/crdt/lwwregister.js +8 -1
- package/dist/crdt/lwwregister.js.map +1 -1
- package/dist/crdt/orset.d.ts +3 -1
- package/dist/crdt/orset.d.ts.map +1 -1
- package/dist/crdt/orset.js +8 -1
- package/dist/crdt/orset.js.map +1 -1
- package/dist/crdt/pncounter.d.ts +3 -0
- package/dist/crdt/pncounter.d.ts.map +1 -1
- package/dist/crdt/pncounter.js +8 -0
- package/dist/crdt/pncounter.js.map +1 -1
- package/dist/crdt/presence.d.ts +3 -1
- package/dist/crdt/presence.d.ts.map +1 -1
- package/dist/crdt/presence.js +8 -1
- package/dist/crdt/presence.js.map +1 -1
- package/dist/crdt/rga.d.ts +54 -0
- package/dist/crdt/rga.d.ts.map +1 -0
- package/dist/crdt/rga.js +122 -0
- package/dist/crdt/rga.js.map +1 -0
- package/dist/errors.d.ts +6 -6
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/layer.d.ts +34 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +31 -0
- package/dist/layer.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -1
- package/dist/schema.js.map +1 -1
- package/dist/sync/delta.d.ts +6 -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/transport/http.d.ts +12 -1
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +4 -0
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/websocket.d.ts +22 -7
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +173 -98
- package/dist/transport/websocket.js.map +1 -1
- package/package.json +2 -2
- package/src/agents.ts +277 -0
- package/src/client.ts +49 -1
- package/src/crdt/awareness.ts +9 -9
- package/src/crdt/crdtmap.ts +9 -0
- package/src/crdt/gcounter.ts +9 -0
- package/src/crdt/lwwregister.ts +9 -1
- package/src/crdt/orset.ts +9 -1
- package/src/crdt/pncounter.ts +9 -0
- package/src/crdt/presence.ts +9 -1
- package/src/crdt/rga.ts +136 -0
- package/src/index.ts +17 -1
- package/src/layer.ts +44 -0
- package/src/schema.ts +2 -1
- package/src/sync/delta.ts +11 -1
- package/src/transport/http.ts +18 -1
- package/src/transport/websocket.ts +220 -96
- package/test/agents.test.ts +104 -0
- package/test/crdt.test.ts +0 -28
- package/test/integration.test.ts +0 -7
- package/test/offline-queue.test.ts +0 -8
- package/test/sync.test.ts +0 -12
package/src/agents.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meridian × Anthropic Claude tool use helpers.
|
|
3
|
+
*
|
|
4
|
+
* Generates Anthropic-compatible `Tool` definitions from a list of CRDT IDs
|
|
5
|
+
* and executes tool calls against a Meridian server via HTTP (no WebSocket).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { getMeridianTools, executeMeridianTool } from "meridian-sdk";
|
|
10
|
+
*
|
|
11
|
+
* const tools = getMeridianTools({ baseUrl, token, namespace, crdtIds: ["counter", "tasks"] });
|
|
12
|
+
*
|
|
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);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { encode, uuidToBytes } from "./codec.js";
|
|
20
|
+
|
|
21
|
+
export interface Tool {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
input_schema: {
|
|
25
|
+
type: "object";
|
|
26
|
+
properties: Record<string, { type: string; description: string }>;
|
|
27
|
+
required?: string[];
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolUseBlock {
|
|
32
|
+
type: "tool_use";
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
input: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MeridianAgentConfig {
|
|
39
|
+
/** HTTP base URL of the Meridian server (e.g. https://meridian.example.com) */
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
/** Bearer token with read+write permissions for the namespace */
|
|
42
|
+
token: string;
|
|
43
|
+
/** Namespace scoping all CRDT operations */
|
|
44
|
+
namespace: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns an Anthropic `Tool[]` array for the given CRDT IDs.
|
|
49
|
+
*
|
|
50
|
+
* Each CRDT exposes up to 3 tools:
|
|
51
|
+
* - `meridian_read_{id}` — read current JSON state
|
|
52
|
+
* - `meridian_increment_{id}` — GCounter/PNCounter increment (amount)
|
|
53
|
+
* - `meridian_set_{id}` — LwwRegister set (value as JSON string)
|
|
54
|
+
* - `meridian_add_{id}` — ORSet add (element as JSON string)
|
|
55
|
+
*/
|
|
56
|
+
export function getMeridianTools(
|
|
57
|
+
config: MeridianAgentConfig,
|
|
58
|
+
crdtIds: string[],
|
|
59
|
+
): Tool[] {
|
|
60
|
+
const tools: Tool[] = [];
|
|
61
|
+
|
|
62
|
+
for (const id of crdtIds) {
|
|
63
|
+
const safeName = id.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
64
|
+
|
|
65
|
+
tools.push({
|
|
66
|
+
name: `meridian_read_${safeName}`,
|
|
67
|
+
description: `Read the current state of the "${id}" CRDT in namespace "${config.namespace}". Returns a JSON object with the CRDT value.`,
|
|
68
|
+
input_schema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
tools.push({
|
|
75
|
+
name: `meridian_increment_${safeName}`,
|
|
76
|
+
description: `Increment the "${id}" counter CRDT by a given amount. Works for GCounter (positive only) and PNCounter (positive or negative).`,
|
|
77
|
+
input_schema: {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
amount: { type: "number", description: "The amount to increment (positive for GCounter, positive or negative for PNCounter)" },
|
|
81
|
+
client_id: { type: "number", description: "Unique numeric client ID for this agent (e.g. 1)" },
|
|
82
|
+
},
|
|
83
|
+
required: ["amount", "client_id"],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
tools.push({
|
|
88
|
+
name: `meridian_set_${safeName}`,
|
|
89
|
+
description: `Set the value of the "${id}" LWW register CRDT. The value is serialized as a JSON string.`,
|
|
90
|
+
input_schema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
value: { type: "string", description: "The new value as a JSON string (e.g. '\"hello\"' or '{\"key\": 1}')" },
|
|
94
|
+
client_id: { type: "number", description: "Unique numeric client ID for this agent" },
|
|
95
|
+
},
|
|
96
|
+
required: ["value", "client_id"],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
tools.push({
|
|
101
|
+
name: `meridian_add_${safeName}`,
|
|
102
|
+
description: `Add an element to the "${id}" ORSet CRDT.`,
|
|
103
|
+
input_schema: {
|
|
104
|
+
type: "object",
|
|
105
|
+
properties: {
|
|
106
|
+
element: { type: "string", description: "The element to add (as a JSON string)" },
|
|
107
|
+
client_id: { type: "number", description: "Unique numeric client ID for this agent" },
|
|
108
|
+
},
|
|
109
|
+
required: ["element", "client_id"],
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return tools;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
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.
|
|
121
|
+
*/
|
|
122
|
+
export async function executeMeridianTool(
|
|
123
|
+
config: MeridianAgentConfig,
|
|
124
|
+
toolUse: ToolUseBlock,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
const { baseUrl, token, namespace } = config;
|
|
127
|
+
const { name, input } = toolUse;
|
|
128
|
+
|
|
129
|
+
// Detect operation type and crdt_id from tool name
|
|
130
|
+
const readMatch = name.match(/^meridian_read_(.+)$/);
|
|
131
|
+
const incrementMatch = name.match(/^meridian_increment_(.+)$/);
|
|
132
|
+
const setMatch = name.match(/^meridian_set_(.+)$/);
|
|
133
|
+
const addMatch = name.match(/^meridian_add_(.+)$/);
|
|
134
|
+
|
|
135
|
+
if (readMatch?.[1]) {
|
|
136
|
+
const crdtId = readMatch[1].replace(/_/g, "-");
|
|
137
|
+
return readCrdt(baseUrl, token, namespace, crdtId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (incrementMatch?.[1]) {
|
|
141
|
+
const crdtId = incrementMatch[1].replace(/_/g, "-");
|
|
142
|
+
const amount = Number(input.amount ?? 1);
|
|
143
|
+
const clientId = Number(input.client_id ?? 1);
|
|
144
|
+
return applyCounterOp(baseUrl, token, namespace, crdtId, clientId, amount);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (setMatch?.[1]) {
|
|
148
|
+
const crdtId = setMatch[1].replace(/_/g, "-");
|
|
149
|
+
const raw = String(input.value ?? "null");
|
|
150
|
+
let value: unknown;
|
|
151
|
+
try { value = JSON.parse(raw); } catch { value = raw; }
|
|
152
|
+
const clientId = Number(input.client_id ?? 1);
|
|
153
|
+
return applyLwwOp(baseUrl, token, namespace, crdtId, clientId, value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (addMatch?.[1]) {
|
|
157
|
+
const crdtId = addMatch[1].replace(/_/g, "-");
|
|
158
|
+
// 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.
|
|
160
|
+
const rawEl = input.element;
|
|
161
|
+
const element: string = typeof rawEl === "string" ? rawEl : JSON.stringify(rawEl ?? null);
|
|
162
|
+
const clientId = Number(input.client_id ?? 1);
|
|
163
|
+
return applyOrSetOp(baseUrl, token, namespace, crdtId, clientId, element);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return JSON.stringify({ error: `Unknown tool: ${name}` });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readCrdt(
|
|
170
|
+
baseUrl: string,
|
|
171
|
+
token: string,
|
|
172
|
+
ns: string,
|
|
173
|
+
id: string,
|
|
174
|
+
): Promise<string> {
|
|
175
|
+
const res = await fetch(`${baseUrl}/v1/namespaces/${ns}/crdts/${id}`, {
|
|
176
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
return JSON.stringify({ error: `HTTP ${res.status}`, crdt_id: id });
|
|
180
|
+
}
|
|
181
|
+
const body = await res.json();
|
|
182
|
+
return JSON.stringify({ crdt_id: id, value: body });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function applyCounterOp(
|
|
186
|
+
baseUrl: string,
|
|
187
|
+
token: string,
|
|
188
|
+
ns: string,
|
|
189
|
+
id: string,
|
|
190
|
+
clientId: number,
|
|
191
|
+
amount: number,
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
// Encode as GCounter op if positive, PNCounter op if negative
|
|
194
|
+
const op = amount >= 0
|
|
195
|
+
? { GCounter: { client_id: clientId, amount } }
|
|
196
|
+
: { PNCounter: { client_id: clientId, amount } };
|
|
197
|
+
|
|
198
|
+
const body = encode(op);
|
|
199
|
+
const res = await fetch(`${baseUrl}/v1/namespaces/${ns}/crdts/${id}/ops`, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: {
|
|
202
|
+
Authorization: `Bearer ${token}`,
|
|
203
|
+
"Content-Type": "application/msgpack",
|
|
204
|
+
},
|
|
205
|
+
body,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (!res.ok) {
|
|
209
|
+
return JSON.stringify({ error: `HTTP ${res.status}`, crdt_id: id });
|
|
210
|
+
}
|
|
211
|
+
return JSON.stringify({ ok: true, crdt_id: id, op: "increment", amount });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function applyLwwOp(
|
|
215
|
+
baseUrl: string,
|
|
216
|
+
token: string,
|
|
217
|
+
ns: string,
|
|
218
|
+
id: string,
|
|
219
|
+
clientId: number,
|
|
220
|
+
value: unknown,
|
|
221
|
+
): Promise<string> {
|
|
222
|
+
const nowMs = Date.now();
|
|
223
|
+
const op = {
|
|
224
|
+
LwwRegister: {
|
|
225
|
+
author: clientId,
|
|
226
|
+
value,
|
|
227
|
+
hlc: { wall_ms: nowMs, logical: 0, node_id: clientId },
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const body = encode(op);
|
|
232
|
+
const res = await fetch(`${baseUrl}/v1/namespaces/${ns}/crdts/${id}/ops`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
Authorization: `Bearer ${token}`,
|
|
236
|
+
"Content-Type": "application/msgpack",
|
|
237
|
+
},
|
|
238
|
+
body,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
const detail = await res.text().catch(() => "");
|
|
243
|
+
return JSON.stringify({ error: `HTTP ${res.status}`, crdt_id: id, detail });
|
|
244
|
+
}
|
|
245
|
+
return JSON.stringify({ ok: true, crdt_id: id, op: "set", value });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function applyOrSetOp(
|
|
249
|
+
baseUrl: string,
|
|
250
|
+
token: string,
|
|
251
|
+
ns: string,
|
|
252
|
+
id: string,
|
|
253
|
+
clientId: number,
|
|
254
|
+
element: unknown,
|
|
255
|
+
): Promise<string> {
|
|
256
|
+
const op = {
|
|
257
|
+
ORSet: {
|
|
258
|
+
Add: { client_id: clientId, element, tag: uuidToBytes(crypto.randomUUID()) },
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const body = encode(op);
|
|
263
|
+
const res = await fetch(`${baseUrl}/v1/namespaces/${ns}/crdts/${id}/ops`, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
Authorization: `Bearer ${token}`,
|
|
267
|
+
"Content-Type": "application/msgpack",
|
|
268
|
+
},
|
|
269
|
+
body,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
const detail = await res.text().catch(() => "");
|
|
274
|
+
return JSON.stringify({ error: `HTTP ${res.status}`, crdt_id: id, detail });
|
|
275
|
+
}
|
|
276
|
+
return JSON.stringify({ ok: true, crdt_id: id, op: "add", element });
|
|
277
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { LwwRegisterHandle } from "./crdt/lwwregister.js";
|
|
|
10
10
|
import { PresenceHandle } from "./crdt/presence.js";
|
|
11
11
|
import { CRDTMapHandle } from "./crdt/crdtmap.js";
|
|
12
12
|
import { AwarenessHandle } from "./crdt/awareness.js";
|
|
13
|
+
import { RGAHandle } from "./crdt/rga.js";
|
|
13
14
|
import {
|
|
14
15
|
decodeGCounterDelta,
|
|
15
16
|
decodePNCounterDelta,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
decodeLwwDelta,
|
|
18
19
|
decodePresenceDelta,
|
|
19
20
|
decodeCRDTMapDelta,
|
|
21
|
+
decodeRGADelta,
|
|
20
22
|
} from "./sync/delta.js";
|
|
21
23
|
import { parseAndValidateToken } from "./auth/token.js";
|
|
22
24
|
import type { ServerMsg, TokenClaims } from "./schema.js";
|
|
@@ -56,13 +58,19 @@ export interface CRDTMapSnapshotEntry {
|
|
|
56
58
|
crdtId: string;
|
|
57
59
|
value: Readonly<CrdtMapValue>;
|
|
58
60
|
}
|
|
61
|
+
export interface RGASnapshotEntry {
|
|
62
|
+
type: "rga";
|
|
63
|
+
crdtId: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}
|
|
59
66
|
export type CRDTSnapshotEntry =
|
|
60
67
|
| GCounterSnapshotEntry
|
|
61
68
|
| PNCounterSnapshotEntry
|
|
62
69
|
| ORSetSnapshotEntry
|
|
63
70
|
| LwwRegisterSnapshotEntry
|
|
64
71
|
| PresenceSnapshotEntry
|
|
65
|
-
| CRDTMapSnapshotEntry
|
|
72
|
+
| CRDTMapSnapshotEntry
|
|
73
|
+
| RGASnapshotEntry;
|
|
66
74
|
|
|
67
75
|
export interface DeltaEvent {
|
|
68
76
|
crdtId: string;
|
|
@@ -127,6 +135,7 @@ export class MeridianClient {
|
|
|
127
135
|
private readonly prHandles = new Map<string, PresenceHandle<unknown>>();
|
|
128
136
|
private readonly cmHandles = new Map<string, CRDTMapHandle>();
|
|
129
137
|
private readonly awHandles = new Map<string, AwarenessHandle<unknown>>();
|
|
138
|
+
private readonly rgaHandles = new Map<string, RGAHandle>();
|
|
130
139
|
|
|
131
140
|
private readonly anyListeners = new Set<() => void>();
|
|
132
141
|
private readonly deltaListeners = new Set<(event: DeltaEvent) => void>();
|
|
@@ -358,6 +367,32 @@ export class MeridianClient {
|
|
|
358
367
|
return handle;
|
|
359
368
|
}
|
|
360
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Returns a handle for an RGA (Replicated Growable Array) CRDT — collaborative text editing.
|
|
372
|
+
*
|
|
373
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
374
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
375
|
+
*
|
|
376
|
+
* @param crdtId - Unique identifier for the CRDT within this namespace.
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```ts
|
|
380
|
+
* const doc = client.rga('doc:content');
|
|
381
|
+
* doc.insert(0, 'Hello');
|
|
382
|
+
* doc.onChange(text => console.log(text));
|
|
383
|
+
* ```
|
|
384
|
+
*/
|
|
385
|
+
rga(crdtId: string): RGAHandle {
|
|
386
|
+
let handle = this.rgaHandles.get(crdtId);
|
|
387
|
+
if (!handle) {
|
|
388
|
+
handle = new RGAHandle({ crdtId, clientId: this.clientId, transport: this.transport });
|
|
389
|
+
this.rgaHandles.set(crdtId, handle);
|
|
390
|
+
this.transport.subscribe(crdtId);
|
|
391
|
+
this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
|
|
392
|
+
}
|
|
393
|
+
return handle;
|
|
394
|
+
}
|
|
395
|
+
|
|
361
396
|
/**
|
|
362
397
|
* Returns a handle for an ephemeral awareness channel.
|
|
363
398
|
*
|
|
@@ -399,6 +434,11 @@ export class MeridianClient {
|
|
|
399
434
|
return this.transport.pendingOpCount;
|
|
400
435
|
}
|
|
401
436
|
|
|
437
|
+
/** P50 and P99 op round-trip latency in ms. Returns null if not enough samples. */
|
|
438
|
+
getLatencyStats(): { p50: number; p99: number; count: number } | null {
|
|
439
|
+
return this.transport.getLatencyStats();
|
|
440
|
+
}
|
|
441
|
+
|
|
402
442
|
/**
|
|
403
443
|
* Subscribe to connection state changes. Returns an unsubscribe function.
|
|
404
444
|
* Useful for building "syncing" indicators in the UI.
|
|
@@ -445,6 +485,8 @@ export class MeridianClient {
|
|
|
445
485
|
crdts.push({ type: "presence", crdtId, online: h.online() as { clientId: number; data: unknown; expiresAtMs: number }[] });
|
|
446
486
|
for (const [crdtId, h] of this.cmHandles)
|
|
447
487
|
crdts.push({ type: "crdtmap", crdtId, value: h.value() });
|
|
488
|
+
for (const [crdtId, h] of this.rgaHandles)
|
|
489
|
+
crdts.push({ type: "rga", crdtId, text: h.value() });
|
|
448
490
|
return {
|
|
449
491
|
namespace: this.namespace,
|
|
450
492
|
clientId: this.clientId,
|
|
@@ -522,6 +564,12 @@ export class MeridianClient {
|
|
|
522
564
|
if (cmHandle) {
|
|
523
565
|
try { cmHandle.applyDelta(decodeCRDTMapDelta(delta_bytes)); } catch { /* stale */ }
|
|
524
566
|
this.notifyDelta(crdt_id, "crdtmap");
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
const rgaHandle = this.rgaHandles.get(crdt_id);
|
|
570
|
+
if (rgaHandle) {
|
|
571
|
+
try { rgaHandle.applyDelta(decodeRGADelta(delta_bytes)); } catch { /* stale */ }
|
|
572
|
+
this.notifyDelta(crdt_id, "rga");
|
|
525
573
|
}
|
|
526
574
|
}
|
|
527
575
|
|
package/src/crdt/awareness.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { Schema as S } from "effect";
|
|
1
|
+
import { Chunk, Effect, Schema as S, Stream } from "effect";
|
|
2
2
|
import type { Schema } from "effect";
|
|
3
3
|
import { encode, decode } from "../codec.js";
|
|
4
4
|
import type { WsTransport } from "../transport/websocket.js";
|
|
5
5
|
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// AwarenessEntry
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
6
|
/**
|
|
11
7
|
* A single peer's awareness state on a given channel.
|
|
12
8
|
*
|
|
@@ -22,10 +18,6 @@ export interface AwarenessEntry<T> {
|
|
|
22
18
|
receivedAt: number;
|
|
23
19
|
}
|
|
24
20
|
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// AwarenessHandle
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
21
|
/**
|
|
30
22
|
* Handle for an ephemeral awareness channel.
|
|
31
23
|
*
|
|
@@ -142,6 +134,14 @@ export class AwarenessHandle<T = unknown> {
|
|
|
142
134
|
};
|
|
143
135
|
}
|
|
144
136
|
|
|
137
|
+
/** Returns a Stream that emits the peer entries on every change. */
|
|
138
|
+
stream(): Stream.Stream<AwarenessEntry<T>[], never, never> {
|
|
139
|
+
return Stream.async<AwarenessEntry<T>[]>((emit) => {
|
|
140
|
+
const unsub = this.onChange((entries) => { void emit(Effect.succeed(Chunk.of(entries))); });
|
|
141
|
+
return Effect.sync(unsub);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
145
|
private emit(): void {
|
|
146
146
|
const entries = this.peers();
|
|
147
147
|
for (const fn of this.listeners) fn(entries);
|
package/src/crdt/crdtmap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Chunk, Effect, Stream } from "effect";
|
|
1
2
|
import { encode } from "../codec.js";
|
|
2
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
3
4
|
import type { CRDTMapDelta, CrdtValueDelta } from "../sync/delta.js";
|
|
@@ -56,6 +57,14 @@ export class CRDTMapHandle {
|
|
|
56
57
|
return () => { this.listeners.delete(listener); };
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/** Returns a Stream that emits the map value on every change. */
|
|
61
|
+
stream(): Stream.Stream<CrdtMapValue, never, never> {
|
|
62
|
+
return Stream.async<CrdtMapValue>((emit) => {
|
|
63
|
+
const unsub = this.onChange((value) => { void emit(Effect.succeed(Chunk.of(value))); });
|
|
64
|
+
return Effect.sync(unsub);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
/** Increment a GCounter key by `amount` (default `1`). */
|
|
60
69
|
incrementCounter(key: string, amount: number = 1, ttlMs?: number): void {
|
|
61
70
|
if (amount <= 0) throw new RangeError("CRDTMap.incrementCounter: amount must be > 0");
|
package/src/crdt/gcounter.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Chunk, Effect, Stream } from "effect";
|
|
1
2
|
import { encode } from "../codec.js";
|
|
2
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
3
4
|
import type { GCounterDelta } from "../sync/delta.js";
|
|
@@ -55,6 +56,14 @@ export class GCounterHandle {
|
|
|
55
56
|
return () => { this.listeners.delete(listener); };
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/** Returns an Effect Stream that emits the counter value on every change. */
|
|
60
|
+
stream(): Stream.Stream<number, never, never> {
|
|
61
|
+
return Stream.async<number>((emit) => {
|
|
62
|
+
const unsub = this.onChange((value) => { void emit(Effect.succeed(Chunk.of(value))); });
|
|
63
|
+
return Effect.sync(unsub);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
/**
|
|
59
68
|
* Increments the counter by `amount` (default `1`) and broadcasts the delta.
|
|
60
69
|
*
|
package/src/crdt/lwwregister.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
1
|
+
import { Chunk, Effect, Schema, Stream } from "effect";
|
|
2
2
|
import { encode } from "../codec.js";
|
|
3
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
4
4
|
import type { LwwDelta, LwwEntry } from "../sync/delta.js";
|
|
@@ -59,6 +59,14 @@ export class LwwRegisterHandle<T> {
|
|
|
59
59
|
return () => { this.listeners.delete(listener); };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Returns a Stream that emits the register value (or null) on every change. */
|
|
63
|
+
stream(): Stream.Stream<T | null, never, never> {
|
|
64
|
+
return Stream.async<T | null>((emit) => {
|
|
65
|
+
const unsub = this.onChange((value) => { void emit(Effect.succeed(Chunk.of(value))); });
|
|
66
|
+
return Effect.sync(unsub);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
/**
|
|
63
71
|
* Writes `value` to the register and broadcasts the operation.
|
|
64
72
|
*
|
package/src/crdt/orset.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
1
|
+
import { Chunk, Effect, Schema, Stream } from "effect";
|
|
2
2
|
import { encode, uuidToBytes } from "../codec.js";
|
|
3
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
4
4
|
import type { ORSetDelta } from "../sync/delta.js";
|
|
@@ -54,6 +54,14 @@ export class ORSetHandle<T> {
|
|
|
54
54
|
return () => { this.listeners.delete(listener); };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Returns a Stream that emits the set elements on every change. */
|
|
58
|
+
stream(): Stream.Stream<T[], never, never> {
|
|
59
|
+
return Stream.async<T[]>((emit) => {
|
|
60
|
+
const unsub = this.onChange((elements) => { void emit(Effect.succeed(Chunk.of(elements))); });
|
|
61
|
+
return Effect.sync(unsub);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/**
|
|
58
66
|
* Adds `element` to the set and broadcasts the operation.
|
|
59
67
|
*
|
package/src/crdt/pncounter.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Chunk, Effect, Stream } from "effect";
|
|
1
2
|
import { encode } from "../codec.js";
|
|
2
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
3
4
|
import type { PNCounterDelta } from "../sync/delta.js";
|
|
@@ -50,6 +51,14 @@ export class PNCounterHandle {
|
|
|
50
51
|
return () => { this.listeners.delete(listener); };
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Returns a Stream that emits the counter value on every change. */
|
|
55
|
+
stream(): Stream.Stream<number, never, never> {
|
|
56
|
+
return Stream.async<number>((emit) => {
|
|
57
|
+
const unsub = this.onChange((value) => { void emit(Effect.succeed(Chunk.of(value))); });
|
|
58
|
+
return Effect.sync(unsub);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
/**
|
|
54
63
|
* Increments the counter by `amount` (default `1`) and broadcasts the delta.
|
|
55
64
|
*
|
package/src/crdt/presence.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
1
|
+
import { Chunk, Effect, Schema, Stream } from "effect";
|
|
2
2
|
import { encode } from "../codec.js";
|
|
3
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
4
4
|
import type { PresenceDelta, PresenceEntryDelta } from "../sync/delta.js";
|
|
@@ -60,6 +60,14 @@ export class PresenceHandle<T> {
|
|
|
60
60
|
return () => { this.listeners.delete(listener); };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Returns a Stream that emits the online entries on every change. */
|
|
64
|
+
stream(): Stream.Stream<PresenceEntry<T>[], never, never> {
|
|
65
|
+
return Stream.async<PresenceEntry<T>[]>((emit) => {
|
|
66
|
+
const unsub = this.onChange((entries) => { void emit(Effect.succeed(Chunk.of(entries))); });
|
|
67
|
+
return Effect.sync(unsub);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
/**
|
|
64
72
|
* Broadcasts a heartbeat that marks the current client as online for `ttlMs`
|
|
65
73
|
* milliseconds (default `30 000`).
|
package/src/crdt/rga.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Chunk, Effect, Stream } from "effect";
|
|
2
|
+
import { encode } from "../codec.js";
|
|
3
|
+
import type { WsTransport } from "../transport/websocket.js";
|
|
4
|
+
import type { RGADelta } from "../sync/delta.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Low-level handle for an RGA (Replicated Growable Array) CRDT — collaborative text editing.
|
|
8
|
+
*
|
|
9
|
+
* Obtained via `MeridianClient.rga()`. The RGA converges concurrent edits from multiple
|
|
10
|
+
* clients using Hybrid Logical Clock IDs and left-origin insertion ordering.
|
|
11
|
+
*
|
|
12
|
+
* Operations use visible-position indices (tombstones are invisible to callers).
|
|
13
|
+
*/
|
|
14
|
+
export class RGAHandle {
|
|
15
|
+
private text = "";
|
|
16
|
+
private readonly crdtId: string;
|
|
17
|
+
private readonly clientId: number;
|
|
18
|
+
private readonly transport: WsTransport;
|
|
19
|
+
private readonly listeners = new Set<(value: string) => void>();
|
|
20
|
+
|
|
21
|
+
constructor(opts: {
|
|
22
|
+
crdtId: string;
|
|
23
|
+
clientId: number;
|
|
24
|
+
transport: WsTransport;
|
|
25
|
+
}) {
|
|
26
|
+
this.crdtId = opts.crdtId;
|
|
27
|
+
this.clientId = opts.clientId;
|
|
28
|
+
this.transport = opts.transport;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the current text content. */
|
|
32
|
+
value(): string {
|
|
33
|
+
return this.text;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Registers a listener called whenever the text changes.
|
|
38
|
+
*
|
|
39
|
+
* @returns An unsubscribe function.
|
|
40
|
+
*/
|
|
41
|
+
onChange(listener: (value: string) => void): () => void {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => { this.listeners.delete(listener); };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns an Effect Stream that emits the text on every change. */
|
|
47
|
+
stream(): Stream.Stream<string, never, never> {
|
|
48
|
+
return Stream.async<string>((emit) => {
|
|
49
|
+
const unsub = this.onChange((value) => { void emit(Effect.succeed(Chunk.of(value))); });
|
|
50
|
+
return Effect.sync(unsub);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inserts `text` at visible position `pos` (0 = before all characters).
|
|
56
|
+
* Characters are inserted one by one in order using the RGA Insert op.
|
|
57
|
+
*
|
|
58
|
+
* @param pos - Visible character position (0-indexed).
|
|
59
|
+
* @param text - String to insert.
|
|
60
|
+
* @param ttlMs - Optional TTL for the op.
|
|
61
|
+
*/
|
|
62
|
+
insert(pos: number, text: string, ttlMs?: number): void {
|
|
63
|
+
if (text.length === 0) return;
|
|
64
|
+
|
|
65
|
+
// Optimistic local update.
|
|
66
|
+
this.text = this.text.slice(0, pos) + text + this.text.slice(pos);
|
|
67
|
+
this.emit();
|
|
68
|
+
|
|
69
|
+
// Send each character as a separate RGA Insert op. The server reorders
|
|
70
|
+
// them using their HLC IDs — sending individually preserves per-char identity.
|
|
71
|
+
const wallMs = Date.now();
|
|
72
|
+
for (let i = 0; i < text.length; i++) {
|
|
73
|
+
const op = encode({
|
|
74
|
+
RGA: {
|
|
75
|
+
Insert: {
|
|
76
|
+
id: { wall_ms: wallMs, logical: i, node_id: this.clientId },
|
|
77
|
+
origin_id: pos + i === 0 ? null : null, // server resolves via WAL
|
|
78
|
+
content: text[i],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
this.transport.send({
|
|
83
|
+
Op: {
|
|
84
|
+
crdt_id: this.crdtId,
|
|
85
|
+
op_bytes: op,
|
|
86
|
+
...(ttlMs !== undefined && { ttl_ms: ttlMs }),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deletes `length` visible characters starting at visible position `pos`.
|
|
94
|
+
*
|
|
95
|
+
* @param pos - Visible character position (0-indexed).
|
|
96
|
+
* @param length - Number of characters to delete.
|
|
97
|
+
* @param ttlMs - Optional TTL for the op.
|
|
98
|
+
*/
|
|
99
|
+
delete(pos: number, length: number, ttlMs?: number): void {
|
|
100
|
+
if (length <= 0) return;
|
|
101
|
+
|
|
102
|
+
// Optimistic local update.
|
|
103
|
+
this.text = this.text.slice(0, pos) + this.text.slice(pos + length);
|
|
104
|
+
this.emit();
|
|
105
|
+
|
|
106
|
+
// Send a single Delete op per character position.
|
|
107
|
+
// The server resolves the actual RGA node IDs from position.
|
|
108
|
+
for (let i = 0; i < length; i++) {
|
|
109
|
+
const op = encode({
|
|
110
|
+
RGA: {
|
|
111
|
+
Delete: {
|
|
112
|
+
pos: pos + i,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
this.transport.send({
|
|
117
|
+
Op: {
|
|
118
|
+
crdt_id: this.crdtId,
|
|
119
|
+
op_bytes: op,
|
|
120
|
+
...(ttlMs !== undefined && { ttl_ms: ttlMs }),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Apply a delta received from the server. Replaces local text with authoritative state. */
|
|
127
|
+
applyDelta(delta: RGADelta): void {
|
|
128
|
+
if (delta.text === this.text) return;
|
|
129
|
+
this.text = delta.text;
|
|
130
|
+
this.emit();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private emit(): void {
|
|
134
|
+
for (const listener of this.listeners) listener(this.text);
|
|
135
|
+
}
|
|
136
|
+
}
|