meridian-sdk 1.2.1 → 1.4.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 (77) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +41 -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 +45 -2
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +86 -4
  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 +45 -0
  20. package/dist/crdt/tree.d.ts.map +1 -1
  21. package/dist/crdt/tree.js +135 -10
  22. package/dist/crdt/tree.js.map +1 -1
  23. package/dist/index.d.ts +11 -4
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +9 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/schema.d.ts +109 -3
  28. package/dist/schema.d.ts.map +1 -1
  29. package/dist/schema.js +59 -1
  30. package/dist/schema.js.map +1 -1
  31. package/dist/sync/delta.d.ts +24 -0
  32. package/dist/sync/delta.d.ts.map +1 -1
  33. package/dist/sync/delta.js +1 -1
  34. package/dist/sync/delta.js.map +1 -1
  35. package/dist/transport/http.d.ts +2 -1
  36. package/dist/transport/http.d.ts.map +1 -1
  37. package/dist/transport/http.js +4 -1
  38. package/dist/transport/http.js.map +1 -1
  39. package/dist/transport/websocket.d.ts +7 -0
  40. package/dist/transport/websocket.d.ts.map +1 -1
  41. package/dist/transport/websocket.js +14 -0
  42. package/dist/transport/websocket.js.map +1 -1
  43. package/dist/undo/UndoManager.d.ts +74 -0
  44. package/dist/undo/UndoManager.d.ts.map +1 -0
  45. package/dist/undo/UndoManager.js +318 -0
  46. package/dist/undo/UndoManager.js.map +1 -0
  47. package/dist/undo/types.d.ts +63 -0
  48. package/dist/undo/types.d.ts.map +1 -0
  49. package/dist/undo/types.js +9 -0
  50. package/dist/undo/types.js.map +1 -0
  51. package/dist/utils/fractional.d.ts +78 -0
  52. package/dist/utils/fractional.d.ts.map +1 -0
  53. package/dist/utils/fractional.js +159 -0
  54. package/dist/utils/fractional.js.map +1 -0
  55. package/dist/validation/index.d.ts +107 -0
  56. package/dist/validation/index.d.ts.map +1 -0
  57. package/dist/validation/index.js +123 -0
  58. package/dist/validation/index.js.map +1 -0
  59. package/package.json +1 -1
  60. package/src/agents.ts +224 -15
  61. package/src/client.ts +109 -4
  62. package/src/conflict/types.ts +60 -0
  63. package/src/crdt/rga.ts +46 -7
  64. package/src/crdt/tree.ts +164 -0
  65. package/src/index.ts +32 -1
  66. package/src/schema.ts +81 -1
  67. package/src/sync/delta.ts +15 -2
  68. package/src/transport/http.ts +6 -0
  69. package/src/transport/websocket.ts +15 -0
  70. package/src/undo/UndoManager.ts +369 -0
  71. package/src/undo/types.ts +74 -0
  72. package/src/utils/fractional.ts +166 -0
  73. package/src/validation/index.ts +137 -0
  74. package/test/conflict.test.ts +242 -0
  75. package/test/fractional.test.ts +127 -0
  76. package/test/undo.test.ts +272 -0
  77. package/test/validation.test.ts +137 -0
package/src/crdt/tree.ts CHANGED
@@ -2,6 +2,9 @@ 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 { TreeDelta, TreeNodeValue } from "../sync/delta.js";
5
+ import type { ConflictEvent, LwwOverwriteEvent, MoveDiscardedEvent, MoveReorderedEvent } from "../conflict/types.js";
6
+ import type { DiscardedMove } from "../sync/delta.js";
7
+ import { type CrdtValidator, runValidator } from "../validation/index.js";
5
8
 
6
9
  /**
7
10
  * Low-level handle for a TreeCRDT — a convergent hierarchical tree.
@@ -13,24 +16,45 @@ import type { TreeDelta, TreeNodeValue } from "../sync/delta.js";
13
16
  * Node IDs are returned by `addNode()` as opaque strings of the form
14
17
  * "wall_ms:logical:node_id" matching the Rust HLC serialization.
15
18
  */
19
+ export interface TreeNodeMeta {
20
+ parentId: string | null;
21
+ position: string;
22
+ /** HLC string "wall_ms:logical:node_id" of the last UpdateNode op on this node. */
23
+ updatedAt: string;
24
+ value: string;
25
+ }
26
+
27
+ /** Maximum number of conflict events retained in the in-memory log. */
28
+ const CONFLICT_LOG_CAP = 200;
29
+
16
30
  export class TreeHandle {
17
31
  private roots: TreeNodeValue[] = [];
18
32
  private readonly crdtId: string;
19
33
  private readonly clientId: number;
20
34
  private readonly transport: WsTransport;
35
+ private readonly validator: CrdtValidator | undefined;
21
36
  private readonly listeners = new Set<(value: { roots: TreeNodeValue[] }) => void>();
37
+ private readonly conflictListeners = new Set<(event: ConflictEvent) => void>();
38
+ private readonly conflictHistory: ConflictEvent[] = [];
22
39
  private opCounter = 0;
40
+ /** Tracks parent, position, updatedAt, and value per node for undo support. */
41
+ private readonly nodeMetaMap = new Map<string, TreeNodeMeta>();
23
42
 
24
43
  constructor(opts: {
25
44
  crdtId: string;
26
45
  clientId: number;
27
46
  transport: WsTransport;
47
+ validator?: CrdtValidator;
28
48
  }) {
29
49
  this.crdtId = opts.crdtId;
30
50
  this.clientId = opts.clientId;
31
51
  this.transport = opts.transport;
52
+ this.validator = opts.validator;
32
53
  }
33
54
 
55
+ /** The CRDT key this handle is bound to. */
56
+ get id(): string { return this.crdtId; }
57
+
34
58
  /** Returns the current tree value. */
35
59
  value(): { roots: TreeNodeValue[] } {
36
60
  return { roots: this.roots };
@@ -64,11 +88,20 @@ export class TreeHandle {
64
88
  * @returns The new node's ID as a string.
65
89
  */
66
90
  addNode(parentId: string | null, position: string, value: string, ttlMs?: number): string {
91
+ runValidator(this.validator, value);
67
92
  const wallMs = Date.now();
68
93
  const logical = this.opCounter++;
69
94
  const id = { wall_ms: wallMs, logical, node_id: this.clientId };
70
95
  const idStr = `${wallMs}:${logical}:${this.clientId}`;
71
96
 
97
+ // Optimistic meta update — allows UndoManager to read state immediately.
98
+ this.nodeMetaMap.set(idStr, {
99
+ parentId,
100
+ position,
101
+ updatedAt: idStr,
102
+ value,
103
+ });
104
+
72
105
  const op = encode({
73
106
  Tree: {
74
107
  AddNode: {
@@ -104,6 +137,12 @@ export class TreeHandle {
104
137
  const logical = this.opCounter++;
105
138
  const opId = { wall_ms: wallMs, logical, node_id: this.clientId };
106
139
 
140
+ // Optimistic meta update.
141
+ const existing = this.nodeMetaMap.get(nodeId);
142
+ if (existing !== undefined) {
143
+ this.nodeMetaMap.set(nodeId, { ...existing, parentId: newParentId, position: newPosition });
144
+ }
145
+
107
146
  const op = encode({
108
147
  Tree: {
109
148
  MoveNode: {
@@ -132,9 +171,17 @@ export class TreeHandle {
132
171
  * @param ttlMs - Optional TTL.
133
172
  */
134
173
  updateNode(nodeId: string, value: string, ttlMs?: number): void {
174
+ runValidator(this.validator, value);
135
175
  const wallMs = Date.now();
136
176
  const logical = this.opCounter++;
137
177
  const updatedAt = { wall_ms: wallMs, logical, node_id: this.clientId };
178
+ const updatedAtStr = `${wallMs}:${logical}:${this.clientId}`;
179
+
180
+ // Optimistic meta update.
181
+ const existing = this.nodeMetaMap.get(nodeId);
182
+ if (existing !== undefined) {
183
+ this.nodeMetaMap.set(nodeId, { ...existing, value, updatedAt: updatedAtStr });
184
+ }
138
185
 
139
186
  const op = encode({
140
187
  Tree: {
@@ -180,12 +227,129 @@ export class TreeHandle {
180
227
  });
181
228
  }
182
229
 
230
+ /**
231
+ * Returns the last-known metadata for a node (parent, position, value, updatedAt).
232
+ * Used by UndoManager to capture pre-op state before mutations.
233
+ */
234
+ getNodeMeta(nodeId: string): TreeNodeMeta | undefined {
235
+ return this.nodeMetaMap.get(nodeId);
236
+ }
237
+
238
+ /**
239
+ * Registers a listener called whenever a conflict is detected in an incoming delta.
240
+ * Conflicts include concurrent move resolutions, LWW overwrites, and discarded moves.
241
+ *
242
+ * @returns An unsubscribe function.
243
+ */
244
+ onConflict(listener: (event: ConflictEvent) => void): () => void {
245
+ this.conflictListeners.add(listener);
246
+ return () => { this.conflictListeners.delete(listener); };
247
+ }
248
+
249
+ /**
250
+ * Returns the last up-to-200 conflict events in chronological order.
251
+ * Useful for rendering an audit log or "track changes" UI.
252
+ */
253
+ conflictLog(): readonly ConflictEvent[] {
254
+ return this.conflictHistory;
255
+ }
256
+
183
257
  /** Apply a delta received from the server. Replaces local state with authoritative tree. */
184
258
  applyDelta(delta: TreeDelta): void {
259
+ this.detectConflicts(delta.roots, null, delta.discarded_moves ?? []);
185
260
  this.roots = delta.roots;
261
+ this.rebuildMetaMap(delta.roots, null);
186
262
  this.emit();
187
263
  }
188
264
 
265
+ /** Emit a conflict event to all listeners and append to history. */
266
+ private emitConflict(event: ConflictEvent): void {
267
+ if (this.conflictHistory.length >= CONFLICT_LOG_CAP) {
268
+ this.conflictHistory.shift();
269
+ }
270
+ this.conflictHistory.push(event);
271
+ for (const listener of this.conflictListeners) listener(event);
272
+ }
273
+
274
+ /**
275
+ * Walks the incoming delta tree and compares each node's server-authoritative
276
+ * parent/position/value against what the local client last recorded.
277
+ * Also processes discarded_moves from the server to emit move_discarded events.
278
+ * Emits conflict events for discrepancies caused by concurrent op resolution.
279
+ */
280
+ private detectConflicts(
281
+ nodes: TreeNodeValue[],
282
+ serverParentId: string | null,
283
+ discardedMoves: DiscardedMove[],
284
+ ): void {
285
+ const now = Date.now();
286
+
287
+ // Emit move_discarded events for server-rejected cycle moves.
288
+ for (const dm of discardedMoves) {
289
+ const hlcToStr = (hlc: { wall_ms: number; logical: number; node_id: number }): string =>
290
+ `${hlc.wall_ms}:${hlc.logical}:${hlc.node_id}`;
291
+ const event: MoveDiscardedEvent = {
292
+ kind: "move_discarded",
293
+ nodeId: hlcToStr(dm.node_id),
294
+ attemptedParentId: dm.attempted_parent_id !== null ? hlcToStr(dm.attempted_parent_id) : null,
295
+ attemptedPosition: dm.attempted_position,
296
+ actualParentId: dm.actual_parent_id !== null ? hlcToStr(dm.actual_parent_id) : null,
297
+ actualPosition: dm.actual_position,
298
+ at: now,
299
+ };
300
+ this.emitConflict(event);
301
+ }
302
+
303
+ for (const node of nodes) {
304
+ const local = this.nodeMetaMap.get(node.id);
305
+ if (local !== undefined) {
306
+ // Detect move reordering: server placed the node somewhere different
307
+ // from what the local optimistic update recorded.
308
+ if (local.parentId !== serverParentId || local.position !== node.position) {
309
+ const event: MoveReorderedEvent = {
310
+ kind: "move_reordered",
311
+ nodeId: node.id,
312
+ localParentId: local.parentId,
313
+ localPosition: local.position,
314
+ serverParentId,
315
+ serverPosition: node.position,
316
+ at: now,
317
+ };
318
+ this.emitConflict(event);
319
+ }
320
+
321
+ // Detect LWW overwrite: server has a different value than local recorded.
322
+ if (local.value !== node.value) {
323
+ const event: LwwOverwriteEvent = {
324
+ kind: "lww_overwrite",
325
+ nodeId: node.id,
326
+ discardedValue: local.value,
327
+ winnerValue: node.value,
328
+ at: now,
329
+ };
330
+ this.emitConflict(event);
331
+ }
332
+ }
333
+ // Recurse into children — serverParentId for children is this node's id.
334
+ // discardedMoves are top-level only (already processed above).
335
+ this.detectConflicts(node.children, node.id, []);
336
+ }
337
+ }
338
+
339
+ /** Rebuilds nodeMetaMap from a full tree snapshot (called on each server delta). */
340
+ private rebuildMetaMap(nodes: TreeNodeValue[], parentId: string | null): void {
341
+ for (const node of nodes) {
342
+ const existing = this.nodeMetaMap.get(node.id);
343
+ this.nodeMetaMap.set(node.id, {
344
+ parentId,
345
+ position: node.position,
346
+ updatedAt: existing?.updatedAt ?? node.id,
347
+ value: node.value,
348
+ });
349
+ this.rebuildMetaMap(node.children, node.id);
350
+ }
351
+ }
352
+
189
353
  private emit(): void {
190
354
  const v = { roots: this.roots };
191
355
  for (const listener of this.listeners) listener(v);
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  MeridianClientConfig,
6
6
  ClientSnapshot,
7
7
  DeltaEvent,
8
+ LiveQueryHandle,
8
9
  CRDTSnapshotEntry,
9
10
  GCounterSnapshotEntry,
10
11
  PNCounterSnapshotEntry,
@@ -54,6 +55,9 @@ export {
54
55
  export {
55
56
  TokenClaims,
56
57
  Permissions,
58
+ PermissionsV1,
59
+ PermissionsV2,
60
+ PermEntry,
57
61
  VectorClock,
58
62
  ClientMsg,
59
63
  ServerMsg,
@@ -65,17 +69,44 @@ export {
65
69
  CrdtGetResponse,
66
70
  CrdtOpResponse,
67
71
  ErrorResponse,
72
+ QuerySpec,
73
+ QueryResult,
74
+ LiveQueryResult,
68
75
  } from "./schema.js";
69
76
  export type { TimestampMs, ClientId } from "./schema.js";
70
77
 
71
- // AI Agents — Claude tool use helpers
78
+ // Undo/redo
79
+ export { UndoManager } from "./undo/UndoManager.js";
80
+ export type { UndoEntry, UndoBatch, RgaInsertEntry, TreeAddEntry, TreeDeleteEntry, TreeMoveEntry, TreeUpdateEntry } from "./undo/types.js";
81
+
82
+ // Fractional indexing — position helpers for TreeCRDT
83
+ export { fi, between, before, after, start, end, spread } from "./utils/fractional.js";
84
+
85
+ // Validation — runtime validators for CRDT string values
86
+ export { CrdtValidationError, zodValidator, fnValidator } from "./validation/index.js";
87
+ export type { CrdtValidator } from "./validation/index.js";
88
+
89
+ // Conflict visualization — events emitted by TreeHandle on concurrent op resolution
90
+ export type { ConflictEvent, MoveDiscardedEvent, MoveReorderedEvent, LwwOverwriteEvent } from "./conflict/types.js";
91
+ export type { DiscardedMove } from "./sync/delta.js";
92
+
93
+ // AI Agents — provider-agnostic tool use helpers (Anthropic, OpenAI, Gemini)
72
94
  export {
73
95
  getMeridianTools,
96
+ toOpenAITools,
97
+ toGeminiTools,
74
98
  executeMeridianTool,
99
+ executeOpenAITool,
100
+ executeGeminiTool,
75
101
  } from "./agents.js";
76
102
  export type {
77
103
  Tool,
104
+ OpenAITool,
105
+ GeminiFunctionDeclaration,
106
+ GeminiTool,
78
107
  ToolUseBlock,
108
+ OpenAIToolCall,
109
+ GeminiFunctionCall,
79
110
  MeridianAgentConfig,
80
111
  } from "./agents.js";
81
112
 
package/src/schema.ts CHANGED
@@ -21,11 +21,34 @@ export const ClientId = Schema.Union(Schema.Number, Schema.BigIntFromSelf.pipe(S
21
21
  )));
22
22
  export type ClientId = number;
23
23
 
24
- export const Permissions = Schema.Struct({
24
+ /** V1 permissions glob-list style (legacy tokens). */
25
+ export const PermissionsV1 = Schema.Struct({
25
26
  read: Schema.Array(Schema.String),
26
27
  write: Schema.Array(Schema.String),
27
28
  admin: Schema.Boolean,
28
29
  });
30
+ export type PermissionsV1 = typeof PermissionsV1.Type;
31
+
32
+ /** A single permission rule in a V2 token. */
33
+ export const PermEntry = Schema.Struct({
34
+ p: Schema.String,
35
+ o: Schema.optional(Schema.Number),
36
+ e: Schema.optional(Schema.Number),
37
+ });
38
+ export type PermEntry = typeof PermEntry.Type;
39
+
40
+ /** V2 fine-grained permissions. */
41
+ export const PermissionsV2 = Schema.Struct({
42
+ v: Schema.Literal(2),
43
+ r: Schema.Array(PermEntry),
44
+ w: Schema.Array(PermEntry),
45
+ admin: Schema.Boolean,
46
+ rl: Schema.optional(Schema.Number),
47
+ });
48
+ export type PermissionsV2 = typeof PermissionsV2.Type;
49
+
50
+ /** Token permissions — V1 or V2. */
51
+ export const Permissions = Schema.Union(PermissionsV2, PermissionsV1);
29
52
  export type Permissions = typeof Permissions.Type;
30
53
 
31
54
  export const TokenClaims = Schema.Struct({
@@ -59,6 +82,25 @@ export const ClientMsg = Schema.Union(
59
82
  data: Schema.Uint8ArrayFromSelf,
60
83
  }),
61
84
  }),
85
+ Schema.Struct({
86
+ SubscribeQuery: Schema.Struct({
87
+ query_id: Schema.String,
88
+ query: Schema.Struct({
89
+ from: Schema.String,
90
+ type: Schema.optional(Schema.String),
91
+ aggregate: Schema.String,
92
+ where: Schema.optional(
93
+ Schema.Struct({
94
+ contains: Schema.optional(Schema.Unknown),
95
+ updated_after: Schema.optional(Schema.Number),
96
+ }),
97
+ ),
98
+ }),
99
+ }),
100
+ }),
101
+ Schema.Struct({
102
+ UnsubscribeQuery: Schema.Struct({ query_id: Schema.String }),
103
+ }),
62
104
  );
63
105
  export type ClientMsg = typeof ClientMsg.Type;
64
106
 
@@ -80,6 +122,13 @@ export const ServerMsg = Schema.Union(
80
122
  data: Schema.Uint8ArrayFromSelf,
81
123
  }),
82
124
  }),
125
+ Schema.Struct({
126
+ QueryResult: Schema.Struct({
127
+ query_id: Schema.String,
128
+ value: Schema.Unknown,
129
+ matched: Schema.Number,
130
+ }),
131
+ }),
83
132
  );
84
133
  export type ServerMsg = typeof ServerMsg.Type;
85
134
 
@@ -135,3 +184,34 @@ export const ErrorResponse = Schema.Struct({
135
184
  message: Schema.String,
136
185
  });
137
186
  export type ErrorResponse = typeof ErrorResponse.Type;
187
+
188
+ export const QuerySpec = Schema.Struct({
189
+ from: Schema.String,
190
+ type: Schema.optional(Schema.String),
191
+ aggregate: Schema.Literal(
192
+ "sum", "max", "min", "count",
193
+ "union", "intersection",
194
+ "latest", "collect",
195
+ "merge",
196
+ ),
197
+ where: Schema.optional(Schema.Struct({
198
+ contains: Schema.optional(Schema.Unknown),
199
+ updatedAfter: Schema.optional(Schema.Number),
200
+ })),
201
+ });
202
+ export type QuerySpec = typeof QuerySpec.Type;
203
+
204
+ export const QueryResult = Schema.Struct({
205
+ value: Schema.Unknown,
206
+ matched: Schema.Number,
207
+ scanned: Schema.Number,
208
+ execution_ms: Schema.Number,
209
+ });
210
+ export type QueryResult = typeof QueryResult.Type;
211
+
212
+ /** Result pushed by the server for a live query subscription (WebSocket only). */
213
+ export const LiveQueryResult = Schema.Struct({
214
+ value: Schema.Unknown,
215
+ matched: Schema.Number,
216
+ });
217
+ export type LiveQueryResult = typeof LiveQueryResult.Type;
package/src/sync/delta.ts CHANGED
@@ -84,16 +84,29 @@ export const decodeRGADelta = (bytes: Uint8Array): RGADelta => {
84
84
  export interface TreeNodeValue {
85
85
  id: string;
86
86
  value: string;
87
+ /** Fractional index string for sibling ordering (e.g. "a0", "b0"). */
88
+ position: string;
87
89
  children: TreeNodeValue[];
88
90
  }
89
91
 
92
+ /** A MoveNode op that was rejected by the server due to cycle prevention. */
93
+ export interface DiscardedMove {
94
+ node_id: { wall_ms: number; logical: number; node_id: number };
95
+ attempted_parent_id: { wall_ms: number; logical: number; node_id: number } | null;
96
+ attempted_position: string;
97
+ actual_parent_id: { wall_ms: number; logical: number; node_id: number } | null;
98
+ actual_position: string;
99
+ }
100
+
90
101
  export interface TreeDelta {
91
102
  roots: TreeNodeValue[];
103
+ /** MoveNode ops discarded due to cycle prevention. Empty in the common case. */
104
+ discarded_moves?: DiscardedMove[];
92
105
  }
93
106
 
94
107
  export const decodeTreeDelta = (bytes: Uint8Array): TreeDelta => {
95
- const raw = decode(bytes) as { roots?: TreeNodeValue[] };
96
- return { roots: raw.roots ?? [] };
108
+ const raw = decode(bytes) as { roots?: TreeNodeValue[]; discarded_moves?: DiscardedMove[] };
109
+ return { roots: raw.roots ?? [], discarded_moves: raw.discarded_moves ?? [] };
97
110
  };
98
111
 
99
112
  export type CrdtValueDelta =
@@ -6,7 +6,9 @@ import {
6
6
  CrdtOpResponse,
7
7
  TokenIssueResponse,
8
8
  ErrorResponse,
9
+ QueryResult,
9
10
  type VectorClock,
11
+ type QuerySpec,
10
12
  } from "../schema.js";
11
13
  import type { Permissions } from "../schema.js";
12
14
 
@@ -72,6 +74,10 @@ export class HttpClient {
72
74
  return this.requestJson(TokenIssueResponse, "POST", `/v1/namespaces/${ns}/tokens`, opts);
73
75
  }
74
76
 
77
+ query(ns: string, spec: QuerySpec): Effect.Effect<QueryResult, HttpError | NetworkError> {
78
+ return this.requestJson(QueryResult, "POST", `/v1/namespaces/${ns}/query`, spec);
79
+ }
80
+
75
81
  private requestJson<A, I>(
76
82
  responseSchema: Schema.Schema<A, I>,
77
83
  method: string,
@@ -49,6 +49,8 @@ export class WsTransport {
49
49
 
50
50
  // State change listeners (replacing fragile function-chain pattern)
51
51
  private readonly stateListeners = new Set<(state: WsState) => void>();
52
+ // Reconnect listeners — called each time a connection is (re-)established.
53
+ private readonly reconnectListeners = new Set<() => void>();
52
54
 
53
55
  private readonly subscriptions = new Map<string, VectorClock>();
54
56
 
@@ -178,6 +180,16 @@ export class WsTransport {
178
180
  return () => { this.stateListeners.delete(listener); };
179
181
  }
180
182
 
183
+ /**
184
+ * Register a callback that fires each time a WebSocket connection is
185
+ * (re-)established. Used by the client to re-send `SubscribeQuery` frames
186
+ * after a reconnect. Returns an unsubscribe function.
187
+ */
188
+ onReconnect(listener: () => void): () => void {
189
+ this.reconnectListeners.add(listener);
190
+ return () => { this.reconnectListeners.delete(listener); };
191
+ }
192
+
181
193
  get currentState(): WsState {
182
194
  return this.state;
183
195
  }
@@ -327,6 +339,9 @@ export class WsTransport {
327
339
  this.sendSubscribe(crdtId, vc);
328
340
  }
329
341
  this.flushPendingOps();
342
+ for (const listener of this.reconnectListeners) {
343
+ listener();
344
+ }
330
345
  }
331
346
 
332
347
  private flushPendingOps(): void {