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.
Files changed (89) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +48 -0
  3. package/dist/agents.d.ts +60 -0
  4. package/dist/agents.d.ts.map +1 -0
  5. package/dist/agents.js +200 -0
  6. package/dist/agents.js.map +1 -0
  7. package/dist/client.d.ts +30 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +43 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/crdt/awareness.d.ts +3 -0
  12. package/dist/crdt/awareness.d.ts.map +1 -1
  13. package/dist/crdt/awareness.js +8 -4
  14. package/dist/crdt/awareness.js.map +1 -1
  15. package/dist/crdt/crdtmap.d.ts +3 -0
  16. package/dist/crdt/crdtmap.d.ts.map +1 -1
  17. package/dist/crdt/crdtmap.js +8 -0
  18. package/dist/crdt/crdtmap.js.map +1 -1
  19. package/dist/crdt/gcounter.d.ts +3 -0
  20. package/dist/crdt/gcounter.d.ts.map +1 -1
  21. package/dist/crdt/gcounter.js +8 -0
  22. package/dist/crdt/gcounter.js.map +1 -1
  23. package/dist/crdt/lwwregister.d.ts +3 -1
  24. package/dist/crdt/lwwregister.d.ts.map +1 -1
  25. package/dist/crdt/lwwregister.js +8 -1
  26. package/dist/crdt/lwwregister.js.map +1 -1
  27. package/dist/crdt/orset.d.ts +3 -1
  28. package/dist/crdt/orset.d.ts.map +1 -1
  29. package/dist/crdt/orset.js +8 -1
  30. package/dist/crdt/orset.js.map +1 -1
  31. package/dist/crdt/pncounter.d.ts +3 -0
  32. package/dist/crdt/pncounter.d.ts.map +1 -1
  33. package/dist/crdt/pncounter.js +8 -0
  34. package/dist/crdt/pncounter.js.map +1 -1
  35. package/dist/crdt/presence.d.ts +3 -1
  36. package/dist/crdt/presence.d.ts.map +1 -1
  37. package/dist/crdt/presence.js +8 -1
  38. package/dist/crdt/presence.js.map +1 -1
  39. package/dist/crdt/rga.d.ts +54 -0
  40. package/dist/crdt/rga.d.ts.map +1 -0
  41. package/dist/crdt/rga.js +122 -0
  42. package/dist/crdt/rga.js.map +1 -0
  43. package/dist/errors.d.ts +6 -6
  44. package/dist/index.d.ts +6 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +5 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/layer.d.ts +34 -0
  49. package/dist/layer.d.ts.map +1 -0
  50. package/dist/layer.js +31 -0
  51. package/dist/layer.js.map +1 -0
  52. package/dist/schema.d.ts +2 -0
  53. package/dist/schema.d.ts.map +1 -1
  54. package/dist/schema.js +2 -1
  55. package/dist/schema.js.map +1 -1
  56. package/dist/sync/delta.d.ts +6 -0
  57. package/dist/sync/delta.d.ts.map +1 -1
  58. package/dist/sync/delta.js +4 -0
  59. package/dist/sync/delta.js.map +1 -1
  60. package/dist/transport/http.d.ts +12 -1
  61. package/dist/transport/http.d.ts.map +1 -1
  62. package/dist/transport/http.js +4 -0
  63. package/dist/transport/http.js.map +1 -1
  64. package/dist/transport/websocket.d.ts +22 -7
  65. package/dist/transport/websocket.d.ts.map +1 -1
  66. package/dist/transport/websocket.js +173 -98
  67. package/dist/transport/websocket.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/agents.ts +277 -0
  70. package/src/client.ts +49 -1
  71. package/src/crdt/awareness.ts +9 -9
  72. package/src/crdt/crdtmap.ts +9 -0
  73. package/src/crdt/gcounter.ts +9 -0
  74. package/src/crdt/lwwregister.ts +9 -1
  75. package/src/crdt/orset.ts +9 -1
  76. package/src/crdt/pncounter.ts +9 -0
  77. package/src/crdt/presence.ts +9 -1
  78. package/src/crdt/rga.ts +136 -0
  79. package/src/index.ts +17 -1
  80. package/src/layer.ts +44 -0
  81. package/src/schema.ts +2 -1
  82. package/src/sync/delta.ts +11 -1
  83. package/src/transport/http.ts +18 -1
  84. package/src/transport/websocket.ts +220 -96
  85. package/test/agents.test.ts +104 -0
  86. package/test/crdt.test.ts +0 -28
  87. package/test/integration.test.ts +0 -7
  88. package/test/offline-queue.test.ts +0 -8
  89. package/test/sync.test.ts +0 -12
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  LwwRegisterSnapshotEntry,
13
13
  PresenceSnapshotEntry,
14
14
  CRDTMapSnapshotEntry,
15
+ RGASnapshotEntry,
15
16
  } from "./client.js";
16
17
 
17
18
  // CRDT handles
@@ -25,10 +26,11 @@ export { CRDTMapHandle } from "./crdt/crdtmap.js";
25
26
  export type { CrdtMapValue } from "./crdt/crdtmap.js";
26
27
  export { AwarenessHandle } from "./crdt/awareness.js";
27
28
  export type { AwarenessEntry } from "./crdt/awareness.js";
29
+ export { RGAHandle } from "./crdt/rga.js";
28
30
 
29
31
  // Transport
30
32
  export { HttpClient } from "./transport/http.js";
31
- export type { HttpClientConfig } from "./transport/http.js";
33
+ export type { HttpClientConfig, HistoryEntry, HistoryResponse } from "./transport/http.js";
32
34
  export { WsTransport } from "./transport/websocket.js";
33
35
  export type { WsTransportConfig, WsState } from "./transport/websocket.js";
34
36
 
@@ -63,6 +65,20 @@ export {
63
65
  } from "./schema.js";
64
66
  export type { TimestampMs, ClientId } from "./schema.js";
65
67
 
68
+ // AI Agents — Claude tool use helpers
69
+ export {
70
+ getMeridianTools,
71
+ executeMeridianTool,
72
+ } from "./agents.js";
73
+ export type {
74
+ Tool,
75
+ ToolUseBlock,
76
+ MeridianAgentConfig,
77
+ } from "./agents.js";
78
+
79
+ // Effect Layer — dependency injection
80
+ export { MeridianService, MeridianLive } from "./layer.js";
81
+
66
82
  // Codec (for advanced use / testing)
67
83
  export {
68
84
  encode,
package/src/layer.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { Context, Effect, Layer } from "effect";
2
+ import { MeridianClient, type MeridianClientConfig } from "./client.js";
3
+ import type { TokenParseError, TokenExpiredError } from "./errors.js";
4
+
5
+ /**
6
+ * Effect Context tag for `MeridianClient`.
7
+ *
8
+ * Use this to inject a `MeridianClient` into Effect programs via `MeridianLive`.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { Effect } from 'effect';
13
+ * import { MeridianService, MeridianLive } from 'meridian-sdk';
14
+ *
15
+ * const program = Effect.gen(function* () {
16
+ * const client = yield* MeridianService;
17
+ * client.gcounter('visitors').increment();
18
+ * });
19
+ *
20
+ * await Effect.runPromise(program.pipe(Effect.provide(MeridianLive(config))));
21
+ * ```
22
+ */
23
+ export class MeridianService extends Context.Tag("MeridianService")<
24
+ MeridianService,
25
+ MeridianClient
26
+ >() {}
27
+
28
+ /**
29
+ * Scoped Layer that creates a `MeridianClient` and closes it on release.
30
+ *
31
+ * The client is created with `MeridianClient.create(config)` and closed
32
+ * automatically when the scope is closed (e.g. at the end of `Effect.runPromise`
33
+ * or when `Layer.build` is interrupted).
34
+ */
35
+ export const MeridianLive = (
36
+ config: MeridianClientConfig,
37
+ ): Layer.Layer<MeridianService, TokenParseError | TokenExpiredError> =>
38
+ Layer.scoped(
39
+ MeridianService,
40
+ Effect.acquireRelease(
41
+ MeridianClient.create(config),
42
+ (client) => Effect.sync(() => client.close()),
43
+ ),
44
+ );
package/src/schema.ts CHANGED
@@ -47,6 +47,7 @@ export const ClientMsg = Schema.Union(
47
47
  crdt_id: Schema.String,
48
48
  op_bytes: Schema.Uint8ArrayFromSelf,
49
49
  ttl_ms: Schema.optional(Schema.Number),
50
+ client_seq: Schema.optional(Schema.Number),
50
51
  }),
51
52
  }),
52
53
  Schema.Struct({
@@ -68,7 +69,7 @@ export const ServerMsg = Schema.Union(
68
69
  delta_bytes: Schema.Uint8ArrayFromSelf,
69
70
  }),
70
71
  }),
71
- Schema.Struct({ Ack: Schema.Struct({ seq: Schema.Number }) }),
72
+ Schema.Struct({ Ack: Schema.Struct({ seq: Schema.Number, client_seq: Schema.optional(Schema.Number) }) }),
72
73
  Schema.Struct({
73
74
  Error: Schema.Struct({ code: Schema.Number, message: Schema.String }),
74
75
  }),
package/src/sync/delta.ts CHANGED
@@ -72,12 +72,22 @@ export const decodePresenceDelta = (bytes: Uint8Array): PresenceDelta => {
72
72
  return { changes: raw.changes ?? {} };
73
73
  };
74
74
 
75
+ export interface RGADelta {
76
+ text: string;
77
+ }
78
+
79
+ export const decodeRGADelta = (bytes: Uint8Array): RGADelta => {
80
+ const raw = decode(bytes) as { text?: string };
81
+ return { text: raw.text ?? "" };
82
+ };
83
+
75
84
  export type CrdtValueDelta =
76
85
  | { GCounter: GCounterDelta }
77
86
  | { PNCounter: PNCounterDelta }
78
87
  | { ORSet: ORSetDelta }
79
88
  | { LwwRegister: LwwDelta }
80
- | { Presence: PresenceDelta };
89
+ | { Presence: PresenceDelta }
90
+ | { RGA: RGADelta };
81
91
 
82
92
  export interface CRDTMapDelta {
83
93
  deltas: Record<string, CrdtValueDelta>;
@@ -16,8 +16,20 @@ export interface HttpClientConfig {
16
16
  timeoutMs?: number;
17
17
  }
18
18
 
19
+ export interface HistoryEntry {
20
+ seq: number;
21
+ timestamp_ms: number;
22
+ op: unknown;
23
+ }
24
+
25
+ export interface HistoryResponse {
26
+ crdt_id: string;
27
+ entries: HistoryEntry[];
28
+ next_seq: number | null;
29
+ }
30
+
19
31
  export class HttpClient {
20
- private readonly baseUrl: string;
32
+ readonly baseUrl: string;
21
33
  private readonly timeoutMs: number;
22
34
  token: string;
23
35
 
@@ -48,6 +60,11 @@ export class HttpClient {
48
60
  return this.request(CrdtGetResponse, "GET", path);
49
61
  }
50
62
 
63
+ getHistory(ns: string, id: string, sinceSeq = 0, limit = 50): Promise<HistoryResponse> {
64
+ const url = `${this.baseUrl}/v1/namespaces/${ns}/crdts/${encodeURIComponent(id)}/history?since_seq=${sinceSeq}&limit=${limit}`;
65
+ return fetch(url, { headers: { Authorization: `Bearer ${this.token}` } }).then(r => r.json() as Promise<HistoryResponse>);
66
+ }
67
+
51
68
  issueToken(
52
69
  ns: string,
53
70
  opts: { client_id: number; ttl_ms: number; permissions: Permissions },
@@ -1,14 +1,23 @@
1
- import { Effect } from "effect";
1
+ import {
2
+ Effect,
3
+ Fiber,
4
+ Layer,
5
+ ManagedRuntime,
6
+ Option,
7
+ Queue,
8
+ Schedule,
9
+ Duration,
10
+ } from "effect";
2
11
  import { encodeClientMsg, decodeServerMsg, encodeVectorClock } from "../codec.js";
3
12
  import type { ServerMsg, VectorClock } from "../schema.js";
4
13
  import {
5
14
  BACKOFF_INITIAL_MS,
6
15
  BACKOFF_MAX_MS,
7
16
  BACKOFF_MULTIPLIER,
8
- JITTER_MULTIPLIER,
9
17
  DEFAULT_TIMEOUT_MS,
10
18
  OFFLINE_QUEUE_MAX,
11
19
  } from "../constants.js";
20
+ import { TransportError } from "../errors.js";
12
21
 
13
22
  export type WsState =
14
23
  | "DISCONNECTED"
@@ -24,46 +33,84 @@ export interface WsTransportConfig {
24
33
  maxBackoffMs?: number;
25
34
  }
26
35
 
36
+ type ClientMsg = Parameters<typeof encodeClientMsg>[0];
37
+
27
38
  export class WsTransport {
28
39
  private readonly config: WsTransportConfig;
29
40
  private readonly maxBackoffMs: number;
30
41
 
31
42
  private ws: WebSocket | null = null;
32
43
  private state: WsState = "DISCONNECTED";
33
- private backoffMs = BACKOFF_INITIAL_MS;
34
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
35
44
  private closed = false;
36
45
 
46
+ // Effect runtime owned by this transport
47
+ private readonly runtime = ManagedRuntime.make(Layer.empty);
48
+ private lifecycleFiber: Fiber.RuntimeFiber<void, never> | null = null;
49
+
50
+ // State change listeners (replacing fragile function-chain pattern)
51
+ private readonly stateListeners = new Set<(state: WsState) => void>();
52
+
37
53
  private readonly subscriptions = new Map<string, VectorClock>();
38
- private readonly pendingOps: Parameters<typeof encodeClientMsg>[0][] = [];
54
+
55
+ // Effect sliding queue — drops oldest when full (same behaviour as previous array)
56
+ private readonly pendingQueue: Queue.Queue<ClientMsg>;
57
+
58
+ // Latency tracking
59
+ private nextClientSeq = 0;
60
+ /** Maps client_seq → `performance.now()` timestamp at send time. */
61
+ private readonly pendingAcks = new Map<number, number>();
62
+ private readonly latencySamples: number[] = []; // rolling window of round-trip ms
63
+ private static readonly LATENCY_WINDOW = 128;
39
64
 
40
65
  constructor(config: WsTransportConfig) {
41
66
  this.config = config;
42
67
  this.maxBackoffMs = config.maxBackoffMs ?? BACKOFF_MAX_MS;
68
+
69
+ // Queue creation is synchronous
70
+ this.pendingQueue = Effect.runSync(Queue.sliding<ClientMsg>(OFFLINE_QUEUE_MAX));
71
+
72
+ // Register initial onStateChange listener if provided
73
+ if (config.onStateChange) {
74
+ this.stateListeners.add(config.onStateChange);
75
+ }
43
76
  }
44
77
 
45
78
  connect(): void {
46
79
  if (this.closed) return;
47
80
  this.closed = false;
48
- this.doConnect();
81
+ this.transitionTo("CONNECTING");
82
+ const ws = this.makeWs();
83
+ this.lifecycleFiber = this.runtime.runFork(this.buildLifecycleEffect(ws));
49
84
  }
50
85
 
51
86
  close(): void {
52
87
  this.closed = true;
53
- this.pendingOps.length = 0;
54
- this.clearReconnectTimer();
88
+ // Drain synchronously so pendingOpCount returns 0 immediately after close()
89
+ Effect.runSync(Queue.takeAll(this.pendingQueue));
90
+ this.runtime.runFork(Queue.shutdown(this.pendingQueue));
55
91
  this.transitionTo("CLOSING");
92
+ if (this.lifecycleFiber !== null) {
93
+ const fiber = this.lifecycleFiber;
94
+ this.lifecycleFiber = null;
95
+ this.runtime.runFork(Fiber.interrupt(fiber));
96
+ }
56
97
  this.ws?.close(1000, "client close");
98
+ this.ws = null;
99
+ void this.runtime.dispose();
57
100
  }
58
101
 
59
102
  reopen(): void {
60
- // Disown the current ws before creating a new one so its close event
61
- // fires with ws !== this.ws and does not trigger scheduleReconnect.
103
+ if (this.lifecycleFiber !== null) {
104
+ const fiber = this.lifecycleFiber;
105
+ this.lifecycleFiber = null;
106
+ this.runtime.runFork(Fiber.interrupt(fiber));
107
+ }
62
108
  const old = this.ws;
63
109
  this.ws = null;
64
110
  old?.close(1000, "reopen");
65
111
  this.closed = false;
66
- this.doConnect();
112
+ this.transitionTo("CONNECTING");
113
+ this.lifecycleFiber = this.runtime.runFork(this.buildLifecycleEffect(this.makeWs()));
67
114
  }
68
115
 
69
116
  subscribe(crdtId: string, sinceVc: VectorClock = {}): void {
@@ -77,19 +124,49 @@ export class WsTransport {
77
124
  this.subscriptions.set(crdtId, vc);
78
125
  }
79
126
 
80
- send(msg: Parameters<typeof encodeClientMsg>[0]): void {
127
+ send(msg: ClientMsg): void {
128
+ const tagged = this.tagOp(msg);
81
129
  if (this.state !== "CONNECTED" || this.ws === null) {
82
- if (this.pendingOps.length >= OFFLINE_QUEUE_MAX) {
83
- this.pendingOps.shift(); // drop oldest to make room
84
- }
85
- this.pendingOps.push(msg);
130
+ // sliding queue drops oldest automatically if full
131
+ this.runtime.runFork(Queue.offer(this.pendingQueue, tagged));
86
132
  return;
87
133
  }
88
- this.ws.send(encodeClientMsg(msg));
134
+ this.ws.send(encodeClientMsg(tagged));
135
+ }
136
+
137
+ /** Returns P50 and P99 op round-trip latency in milliseconds, or null if not enough samples. */
138
+ getLatencyStats(): { p50: number; p99: number; count: number } | null {
139
+ if (this.latencySamples.length < 2) return null;
140
+ const sorted = [...this.latencySamples].sort((a, b) => a - b);
141
+ const p50 = sorted[Math.floor(sorted.length * 0.5)] ?? 0;
142
+ const p99 = sorted[Math.floor(sorted.length * 0.99)] ?? 0;
143
+ return { p50, p99, count: sorted.length };
144
+ }
145
+
146
+ private tagOp(msg: ClientMsg): ClientMsg {
147
+ if (!("Op" in msg)) return msg;
148
+ const seq = this.nextClientSeq++;
149
+ this.pendingAcks.set(seq, performance.now());
150
+ return { Op: { ...msg.Op, client_seq: seq } };
151
+ }
152
+
153
+ private recordAck(clientSeq: number): void {
154
+ const sentAt = this.pendingAcks.get(clientSeq);
155
+ if (sentAt === undefined) return;
156
+ this.pendingAcks.delete(clientSeq);
157
+ const rtt = performance.now() - sentAt;
158
+ this.latencySamples.push(rtt);
159
+ if (this.latencySamples.length > WsTransport.LATENCY_WINDOW) {
160
+ this.latencySamples.shift();
161
+ }
89
162
  }
90
163
 
91
164
  get pendingOpCount(): number {
92
- return this.pendingOps.length;
165
+ try {
166
+ return Effect.runSync(Queue.size(this.pendingQueue));
167
+ } catch {
168
+ return 0;
169
+ }
93
170
  }
94
171
 
95
172
  /**
@@ -97,18 +174,8 @@ export class WsTransport {
97
174
  * Compatible with React's `useSyncExternalStore` subscribe parameter.
98
175
  */
99
176
  onStateChange(listener: (state: WsState) => void): () => void {
100
- const prev = this.config.onStateChange;
101
- this.config.onStateChange = (s) => {
102
- prev?.(s);
103
- listener(s);
104
- };
105
- return () => {
106
- if (prev !== undefined) {
107
- this.config.onStateChange = prev;
108
- } else {
109
- delete (this.config as Partial<WsTransportConfig>).onStateChange;
110
- }
111
- };
177
+ this.stateListeners.add(listener);
178
+ return () => { this.stateListeners.delete(listener); };
112
179
  }
113
180
 
114
181
  get currentState(): WsState {
@@ -118,90 +185,141 @@ export class WsTransport {
118
185
  waitForConnected(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
119
186
  if (this.state === "CONNECTED") return Promise.resolve();
120
187
  return new Promise((resolve, reject) => {
121
- const orig = this.config.onStateChange;
122
- const restore = () => {
123
- if (orig !== undefined) {
124
- this.config.onStateChange = orig;
125
- } else {
126
- delete (this.config as Partial<WsTransportConfig>).onStateChange;
127
- }
128
- };
129
188
  const timer = setTimeout(() => {
130
- restore();
189
+ this.stateListeners.delete(listener);
131
190
  reject(new Error("WsTransport: connect timeout"));
132
191
  }, timeoutMs);
133
- this.config.onStateChange = (s) => {
134
- orig?.(s);
192
+ const listener = (s: WsState) => {
135
193
  if (s === "CONNECTED") {
136
194
  clearTimeout(timer);
137
- restore();
195
+ this.stateListeners.delete(listener);
138
196
  resolve();
139
197
  }
140
198
  };
199
+ this.stateListeners.add(listener);
141
200
  });
142
201
  }
143
202
 
144
- private doConnect(): void {
145
- if (this.closed) return;
146
- this.transitionTo("CONNECTING");
203
+ // ---------------------------------------------------------------------------
204
+ // Effect lifecycle
205
+ // ---------------------------------------------------------------------------
147
206
 
207
+ private makeWs(): {
208
+ ws: WebSocket;
209
+ connected: Promise<WebSocket>;
210
+ cancel: () => void;
211
+ } {
148
212
  const url = `${this.config.url}${this.config.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.config.token)}`;
149
213
  const ws = new WebSocket(url);
150
214
  ws.binaryType = "arraybuffer";
151
- this.ws = ws;
152
215
 
153
- ws.addEventListener("open", () => {
154
- if (ws !== this.ws) return;
155
- this.backoffMs = BACKOFF_INITIAL_MS;
216
+ // Register open/error listeners synchronously so they fire even if the
217
+ // Fiber hasn't started yet (runFork is async).
218
+ let resolve!: (ws: WebSocket) => void;
219
+ let reject!: (e: TransportError) => void;
220
+ const connected = new Promise<WebSocket>((res, rej) => { resolve = res; reject = rej; });
221
+
222
+ const onOpen = () => {
223
+ this.ws = ws;
156
224
  this.transitionTo("CONNECTED");
157
225
  this.resubscribeAll();
158
- });
226
+ resolve(ws);
227
+ };
228
+ const onError = () => {
229
+ reject(new TransportError({ message: "WebSocket connection failed" }));
230
+ };
231
+ ws.addEventListener("open", onOpen, { once: true });
232
+ ws.addEventListener("error", onError, { once: true });
159
233
 
160
- ws.addEventListener("message", (event: MessageEvent) => {
161
- if (ws !== this.ws) return;
162
- const bytes = new Uint8Array(event.data as ArrayBuffer);
163
- Effect.runPromise(decodeServerMsg(bytes)).then(
164
- (msg) => { this.config.onMessage(msg); },
165
- (e) => { console.warn("[meridian] failed to decode server message", e); },
166
- );
167
- });
234
+ const cancel = () => {
235
+ ws.removeEventListener("open", onOpen);
236
+ ws.removeEventListener("error", onError);
237
+ ws.close(1000, "fiber interrupt");
238
+ };
239
+ return { ws, connected, cancel };
240
+ }
168
241
 
169
- ws.addEventListener("close", () => {
170
- if (ws !== this.ws) return;
171
- this.ws = null;
172
- if (!this.closed) {
173
- this.transitionTo("DISCONNECTED");
174
- this.scheduleReconnect();
242
+ private buildLifecycleEffect(wsHandle: ReturnType<WsTransport["makeWs"]>): Effect.Effect<void, never> {
243
+ const { ws, connected, cancel } = wsHandle;
244
+
245
+ const connectOnce = Effect.async<WebSocket, TransportError>((resume) => {
246
+ if (this.closed) {
247
+ cancel();
248
+ resume(Effect.fail(new TransportError({ message: "transport closed" })));
249
+ return Effect.void;
175
250
  }
176
- });
177
251
 
178
- ws.addEventListener("error", () => {
179
- // HACK: The "close" event fires right after an error — let that handler drive reconnect logic.
252
+ // connected already has the listeners; just await its outcome
253
+ connected.then(
254
+ (w) => resume(Effect.succeed(w)),
255
+ () => resume(Effect.fail(new TransportError({ message: "WebSocket connection failed" }))),
256
+ );
257
+
258
+ // Finalizer — called if Fiber is interrupted before open
259
+ return Effect.sync(cancel);
180
260
  });
181
- }
182
261
 
183
- private scheduleReconnect(): void {
184
- if (this.closed) return;
185
- const jitter = this.backoffMs * JITTER_MULTIPLIER * (Math.random() * 2 - 1);
186
- const delay = Math.round(this.backoffMs + jitter);
187
- this.reconnectTimer = setTimeout(() => {
188
- this.reconnectTimer = null;
189
- this.doConnect();
190
- }, delay);
191
- this.backoffMs = Math.min(this.backoffMs * BACKOFF_MULTIPLIER, this.maxBackoffMs);
192
- }
193
-
194
- private clearReconnectTimer(): void {
195
- if (this.reconnectTimer !== null) {
196
- clearTimeout(this.reconnectTimer);
197
- this.reconnectTimer = null;
198
- }
262
+ const handleConnection = (_ws: WebSocket): Effect.Effect<void, TransportError> =>
263
+ Effect.async((resume) => {
264
+ const onMessage = (event: MessageEvent) => {
265
+ const bytes = new Uint8Array(event.data as ArrayBuffer);
266
+ Effect.runPromise(decodeServerMsg(bytes)).then(
267
+ (msg) => {
268
+ if ("Ack" in msg && msg.Ack.client_seq !== undefined) {
269
+ this.recordAck(msg.Ack.client_seq);
270
+ }
271
+ this.config.onMessage(msg);
272
+ },
273
+ (e) => { console.warn("[meridian] failed to decode server message", e); },
274
+ );
275
+ };
276
+ const onClose = () => {
277
+ this.ws = null;
278
+ resume(Effect.fail(new TransportError({ message: "WebSocket closed" })));
279
+ };
280
+
281
+ ws.addEventListener("message", onMessage);
282
+ ws.addEventListener("close", onClose, { once: true });
283
+
284
+ // Finalizer — clean up listeners if Fiber is interrupted
285
+ return Effect.sync(() => {
286
+ ws.removeEventListener("message", onMessage);
287
+ ws.removeEventListener("close", onClose);
288
+ ws.close(1000, "fiber interrupt");
289
+ });
290
+ });
291
+
292
+ const reconnectSchedule = Schedule.exponential(
293
+ Duration.millis(BACKOFF_INITIAL_MS),
294
+ BACKOFF_MULTIPLIER,
295
+ ).pipe(
296
+ Schedule.map((d) => Duration.min(d, Duration.millis(this.maxBackoffMs))),
297
+ Schedule.jittered,
298
+ );
299
+
300
+ return connectOnce.pipe(
301
+ Effect.flatMap(handleConnection),
302
+ Effect.tapError(() => Effect.sync(() => {
303
+ if (!this.closed) this.transitionTo("DISCONNECTED");
304
+ })),
305
+ Effect.retry(reconnectSchedule),
306
+ Effect.catchAll(() => Effect.void),
307
+ );
199
308
  }
200
309
 
310
+ // ---------------------------------------------------------------------------
311
+ // Internals
312
+ // ---------------------------------------------------------------------------
313
+
201
314
  private transitionTo(next: WsState): void {
202
315
  if (this.state === next) return;
203
316
  this.state = next;
317
+ // Notify initial listener from config (for backward compat with client.ts)
204
318
  this.config.onStateChange?.(next);
319
+ // Notify all registered listeners
320
+ for (const listener of this.stateListeners) {
321
+ listener(next);
322
+ }
205
323
  }
206
324
 
207
325
  private resubscribeAll(): void {
@@ -212,18 +330,24 @@ export class WsTransport {
212
330
  }
213
331
 
214
332
  private flushPendingOps(): void {
215
- const ops = this.pendingOps.splice(0);
216
- for (let i = 0; i < ops.length; i++) {
217
- const op = ops[i];
218
- if (op === undefined) continue;
219
- try {
220
- this.ws?.send(encodeClientMsg(op));
221
- } catch {
222
- // WebSocket closed between open and flush — re-queue remaining ops.
223
- this.pendingOps.unshift(...ops.slice(i));
224
- break;
225
- }
226
- }
333
+ this.runtime.runFork(
334
+ Effect.gen((function* (this: WsTransport) {
335
+ for (;;) {
336
+ const next: Option.Option<ClientMsg> = yield* Queue.poll(this.pendingQueue);
337
+ if (Option.isNone(next)) break;
338
+ if (this.ws === null || this.state !== "CONNECTED") {
339
+ yield* Queue.offer(this.pendingQueue, next.value);
340
+ break;
341
+ }
342
+ try {
343
+ this.ws.send(encodeClientMsg(this.tagOp(next.value)));
344
+ } catch {
345
+ yield* Queue.offer(this.pendingQueue, next.value);
346
+ break;
347
+ }
348
+ }
349
+ }).bind(this)),
350
+ );
227
351
  }
228
352
 
229
353
  private sendSubscribe(crdtId: string, vc: VectorClock): void {