meridian-sdk 1.3.0 → 1.4.1

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 (58) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +99 -0
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/agents.js +0 -6
  5. package/dist/agents.js.map +1 -1
  6. package/dist/auth/permissions.d.ts +45 -0
  7. package/dist/auth/permissions.d.ts.map +1 -0
  8. package/dist/auth/permissions.js +101 -0
  9. package/dist/auth/permissions.js.map +1 -0
  10. package/dist/client.d.ts +80 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +147 -2
  13. package/dist/client.js.map +1 -1
  14. package/dist/codec.d.ts.map +1 -1
  15. package/dist/codec.js +4 -4
  16. package/dist/codec.js.map +1 -1
  17. package/dist/index.d.ts +6 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/rpc.d.ts +77 -0
  22. package/dist/rpc.d.ts.map +1 -0
  23. package/dist/rpc.js +85 -0
  24. package/dist/rpc.js.map +1 -0
  25. package/dist/schema.d.ts +46 -0
  26. package/dist/schema.d.ts.map +1 -1
  27. package/dist/schema.js +41 -0
  28. package/dist/schema.js.map +1 -1
  29. package/dist/transport/http.d.ts +21 -4
  30. package/dist/transport/http.d.ts.map +1 -1
  31. package/dist/transport/http.js +20 -1
  32. package/dist/transport/http.js.map +1 -1
  33. package/dist/transport/websocket.d.ts +7 -0
  34. package/dist/transport/websocket.d.ts.map +1 -1
  35. package/dist/transport/websocket.js +14 -6
  36. package/dist/transport/websocket.js.map +1 -1
  37. package/dist/undo/UndoManager.d.ts.map +1 -1
  38. package/dist/undo/UndoManager.js +8 -6
  39. package/dist/undo/UndoManager.js.map +1 -1
  40. package/dist/utils/fractional.js +3 -3
  41. package/dist/utils/fractional.js.map +1 -1
  42. package/dist/validation/index.d.ts +1 -1
  43. package/dist/validation/index.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/agents.ts +1 -17
  46. package/src/auth/permissions.ts +130 -0
  47. package/src/client.ts +174 -2
  48. package/src/codec.ts +6 -6
  49. package/src/index.ts +10 -0
  50. package/src/rpc.ts +156 -0
  51. package/src/schema.ts +57 -0
  52. package/src/transport/http.ts +32 -2
  53. package/src/transport/websocket.ts +15 -7
  54. package/src/undo/UndoManager.ts +7 -6
  55. package/src/utils/fractional.ts +3 -3
  56. package/src/validation/index.ts +1 -1
  57. package/test/integration.test.ts +0 -4
  58. package/test/permissions.test.ts +229 -0
package/src/agents.ts CHANGED
@@ -51,9 +51,6 @@ export interface Tool {
51
51
  };
52
52
  }
53
53
 
54
- // ---------------------------------------------------------------------------
55
- // OpenAI adapter
56
- // ---------------------------------------------------------------------------
57
54
 
58
55
  /** OpenAI-compatible tool definition (Chat Completions `tools` array entry). */
59
56
  export interface OpenAITool {
@@ -92,9 +89,6 @@ export function toOpenAITools(tools: Tool[]): OpenAITool[] {
92
89
  }));
93
90
  }
94
91
 
95
- // ---------------------------------------------------------------------------
96
- // Gemini adapter
97
- // ---------------------------------------------------------------------------
98
92
 
99
93
  /** Gemini FunctionDeclaration (single entry inside a `Tool.functionDeclarations` array). */
100
94
  export interface GeminiFunctionDeclaration {
@@ -134,9 +128,7 @@ export function toGeminiTools(tools: Tool[]): GeminiTool {
134
128
  };
135
129
  }
136
130
 
137
- // ---------------------------------------------------------------------------
138
- // Provider-specific input types (structural matches — no provider SDK deps)
139
- // ---------------------------------------------------------------------------
131
+
140
132
 
141
133
  /** Anthropic tool_use block — matches `ContentBlock` from `@anthropic-ai/sdk`. */
142
134
  export interface ToolUseBlock {
@@ -248,10 +240,6 @@ export function getMeridianTools(
248
240
  return tools;
249
241
  }
250
242
 
251
- // ---------------------------------------------------------------------------
252
- // Shared dispatcher (private)
253
- // ---------------------------------------------------------------------------
254
-
255
243
  /**
256
244
  * Core dispatch logic — routes a (name, input) pair to the right HTTP operation.
257
245
  * All provider-specific executors delegate here.
@@ -302,10 +290,6 @@ async function dispatchTool(
302
290
  return JSON.stringify({ error: `Unknown tool: ${name}` });
303
291
  }
304
292
 
305
- // ---------------------------------------------------------------------------
306
- // Provider executors
307
- // ---------------------------------------------------------------------------
308
-
309
293
  /**
310
294
  * Execute a Meridian tool call returned by **Anthropic Claude**.
311
295
  *
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Client-side permission evaluation — mirrors the Rust `claims.rs` logic exactly.
3
+ *
4
+ * Lets callers check `canRead` / `canWrite` locally without a round-trip to the
5
+ * server. Useful for disabling UI elements before attempting an op that would
6
+ * fail with 403.
7
+ */
8
+
9
+ import type { Permissions, PermEntry } from "../schema.js";
10
+
11
+ export const OpMasks = {
12
+ /** Allow all ops (sentinel — 0 means no restriction). */
13
+ ALL: 0,
14
+
15
+ // GCounter / PNCounter
16
+ GC_INCREMENT: 0b0000_0001,
17
+ PN_INCREMENT: 0b0000_0001,
18
+ PN_DECREMENT: 0b0000_0010,
19
+
20
+ // ORSet
21
+ OR_ADD: 0b0000_0001,
22
+ OR_REMOVE: 0b0000_0010,
23
+
24
+ // LWW Register
25
+ LWW_SET: 0b0000_0001,
26
+
27
+ // Presence
28
+ PRESENCE_UPDATE: 0b0000_0001,
29
+
30
+ // RGA
31
+ RGA_INSERT: 0b0000_0001,
32
+ RGA_DELETE: 0b0000_0010,
33
+
34
+ // Tree
35
+ TREE_ADD: 0b0000_0001,
36
+ TREE_MOVE: 0b0000_0010,
37
+ TREE_UPDATE: 0b0000_0100,
38
+ TREE_DELETE: 0b0000_1000,
39
+
40
+ // CRDTMap
41
+ MAP_WRITE: 0b0000_0001,
42
+ } as const;
43
+
44
+ export type OpMask = number;
45
+
46
+ function globMatch(pattern: string, value: string): boolean {
47
+ if (pattern === "*") return true;
48
+ const starPos = pattern.indexOf("*");
49
+ if (starPos === -1) return pattern === value;
50
+
51
+ const prefix = pattern.slice(0, starPos);
52
+ const suffix = pattern.slice(starPos + 1);
53
+ if (!value.startsWith(prefix)) return false;
54
+ const rest = value.slice(prefix.length);
55
+ if (suffix === "") return true;
56
+ for (let i = 0; i <= rest.length; i++) {
57
+ if (globMatch(suffix, rest.slice(i))) return true;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ function isAllMask(mask: number): boolean {
63
+ return mask === 0 || mask === 0xffff;
64
+ }
65
+
66
+ function entryMatches(
67
+ entry: PermEntry,
68
+ crdtId: string,
69
+ opMask: OpMask,
70
+ nowMs: number,
71
+ clientId: number,
72
+ ): boolean {
73
+ // TTL gate
74
+ if (entry.e !== undefined && nowMs >= entry.e) return false;
75
+
76
+ // Expand {clientId} placeholder
77
+ const pattern = entry.p.includes("{clientId}")
78
+ ? entry.p.replaceAll("{clientId}", String(clientId))
79
+ : entry.p;
80
+
81
+ if (!globMatch(pattern, crdtId)) return false;
82
+
83
+ const ruleOp = entry.o ?? OpMasks.ALL;
84
+ if (isAllMask(ruleOp) || isAllMask(opMask)) return true;
85
+ return (opMask & ruleOp) !== 0;
86
+ }
87
+
88
+ /**
89
+ * Check whether the token's permissions allow reading `crdtId`.
90
+ *
91
+ * @param permissions - parsed from `TokenClaims.permissions`
92
+ * @param crdtId - e.g. `"gc:views"`, `"or:cart-42"`
93
+ * @param clientId - `TokenClaims.client_id` (needed for `{clientId}` expansion)
94
+ * @param nowMs - current time in ms (default: `Date.now()`)
95
+ */
96
+ export function canRead(
97
+ permissions: Permissions,
98
+ crdtId: string,
99
+ clientId: number,
100
+ nowMs = Date.now(),
101
+ ): boolean {
102
+ if ("v" in permissions) {
103
+ // V2
104
+ return permissions.r.some((e) => entryMatches(e, crdtId, OpMasks.ALL, nowMs, clientId));
105
+ }
106
+ // V1
107
+ return permissions.read.some((p) => globMatch(p, crdtId));
108
+ }
109
+
110
+ /**
111
+ * Check whether the token's permissions allow writing `crdtId`.
112
+ *
113
+ * @param opMask - op-level bitmask (e.g. `OpMasks.OR_ADD`). Omit or pass `0` to
114
+ * check key-level access without op granularity (V2 rule with a
115
+ * mask will still be matched if the caller passes `ALL=0`).
116
+ */
117
+ export function canWrite(
118
+ permissions: Permissions,
119
+ crdtId: string,
120
+ clientId: number,
121
+ opMask: OpMask = OpMasks.ALL,
122
+ nowMs = Date.now(),
123
+ ): boolean {
124
+ if ("v" in permissions) {
125
+ // V2
126
+ return permissions.w.some((e) => entryMatches(e, crdtId, opMask, nowMs, clientId));
127
+ }
128
+ // V1
129
+ return permissions.write.some((p) => globMatch(p, crdtId));
130
+ }
package/src/client.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Effect, type Schema } from "effect";
2
+ import type { QuerySpec, QueryResult, LiveQueryResult } from "./schema.js";
2
3
  import type { WsState } from "./transport/websocket.js";
3
4
  import { WsTransport } from "./transport/websocket.js";
4
5
  import type { CrdtMapValue } from "./crdt/crdtmap.js";
@@ -25,6 +26,9 @@ import {
25
26
  } from "./sync/delta.js";
26
27
  import type { TreeNodeValue } from "./sync/delta.js";
27
28
  import { parseAndValidateToken } from "./auth/token.js";
29
+ import { canRead as evalCanRead, canWrite as evalCanWrite } from "./auth/permissions.js";
30
+ import { decode as msgpackDecode } from "./codec.js";
31
+ import type { OpMask } from "./auth/permissions.js";
28
32
  import type { ServerMsg, TokenClaims } from "./schema.js";
29
33
  import type { TokenParseError, TokenExpiredError } from "./errors.js";
30
34
 
@@ -82,6 +86,17 @@ export type CRDTSnapshotEntry =
82
86
  | RGASnapshotEntry
83
87
  | TreeSnapshotEntry;
84
88
 
89
+ /**
90
+ * A handle returned by `client.liveQuery()`. Receives pushed `LiveQueryResult`
91
+ * frames whenever matching CRDTs change.
92
+ */
93
+ export interface LiveQueryHandle {
94
+ /** Register a listener for live query result pushes. Returns an unsubscribe fn. */
95
+ onResult(listener: (result: LiveQueryResult) => void): () => void;
96
+ /** Cancel the subscription and clean up. */
97
+ close(): void;
98
+ }
99
+
85
100
  export interface DeltaEvent {
86
101
  crdtId: string;
87
102
  type: CRDTSnapshotEntry["type"];
@@ -152,6 +167,16 @@ export class MeridianClient {
152
167
  private readonly deltaListeners = new Set<(event: DeltaEvent) => void>();
153
168
  private readonly handleUnsubs: Array<() => void> = [];
154
169
 
170
+ // Live query subscriptions: query_id → { spec, listeners }
171
+ private readonly liveQueries = new Map<string, {
172
+ spec: QuerySpec;
173
+ listeners: Set<(result: LiveQueryResult) => void>;
174
+ }>();
175
+ private liveQueryCounter = 0;
176
+
177
+ // RPC frame listeners — registered by createMeridianRpc
178
+ private readonly rpcListeners = new Set<(frame: unknown) => void>();
179
+
155
180
  private constructor(config: MeridianClientConfig, claims: TokenClaims) {
156
181
  this.namespace = config.namespace;
157
182
  this.claims = claims;
@@ -176,6 +201,10 @@ export class MeridianClient {
176
201
  // Internal listener: routes transport state changes through onAnyChange
177
202
  // so devtools only needs one subscription (avoids WsTransport chaining issue).
178
203
  this.transport.onStateChange(() => { this.notifyAnyChange(); });
204
+
205
+ // Re-send all active SubscribeQuery frames after each reconnect so the
206
+ // server-side registry is restored and live queries keep firing.
207
+ this.transport.onReconnect(() => { this.resubscribeLiveQueries(); });
179
208
  }
180
209
 
181
210
  /**
@@ -396,7 +425,7 @@ export class MeridianClient {
396
425
  rga(crdtId: string, opts?: { validator?: CrdtValidator }): RGAHandle {
397
426
  let handle = this.rgaHandles.get(crdtId);
398
427
  if (!handle) {
399
- handle = new RGAHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined && { validator: opts.validator }) });
428
+ handle = new RGAHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined ? { validator: opts.validator } : {}) });
400
429
  this.rgaHandles.set(crdtId, handle);
401
430
  this.transport.subscribe(crdtId);
402
431
  this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
@@ -426,7 +455,7 @@ export class MeridianClient {
426
455
  tree(crdtId: string, opts?: { validator?: CrdtValidator }): TreeHandle {
427
456
  let handle = this.treeHandles.get(crdtId);
428
457
  if (!handle) {
429
- handle = new TreeHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined && { validator: opts.validator }) });
458
+ handle = new TreeHandle({ crdtId, clientId: this.clientId, transport: this.transport, ...(opts?.validator !== undefined ? { validator: opts.validator } : {}) });
430
459
  this.treeHandles.set(crdtId, handle);
431
460
  this.transport.subscribe(crdtId);
432
461
  this.handleUnsubs.push(handle.onChange(() => { this.notifyAnyChange(); }));
@@ -488,6 +517,26 @@ export class MeridianClient {
488
517
  return this.transport.onStateChange(listener);
489
518
  }
490
519
 
520
+ /**
521
+ * Register a listener for incoming RPC frames (used by `createMeridianRpc`).
522
+ * Frames arrive as `AwarenessBroadcast` messages with key `"__rpc__"`.
523
+ * Returns an unsubscribe function.
524
+ * @internal
525
+ */
526
+ onRpcFrame(listener: (frame: unknown) => void): () => void {
527
+ this.rpcListeners.add(listener);
528
+ return () => { this.rpcListeners.delete(listener); };
529
+ }
530
+
531
+ /**
532
+ * Send a raw msgpack-encoded `ClientMsg` over the WebSocket transport.
533
+ * Used by `createMeridianRpc` to send typed RPC frames.
534
+ * @internal
535
+ */
536
+ sendRpcFrame(data: Uint8Array): void {
537
+ this.transport.send({ AwarenessUpdate: { key: "__rpc__", data } });
538
+ }
539
+
491
540
  /**
492
541
  * Subscribe to any state change (CRDT value or connection state).
493
542
  * Fires whenever any handle emits or the WebSocket transitions.
@@ -546,11 +595,49 @@ export class MeridianClient {
546
595
  * using `<MeridianProvider>` in React, the provider calls this automatically
547
596
  * on unmount.
548
597
  */
598
+
599
+ /**
600
+ * Returns `true` if the token's permissions allow reading `crdtId`.
601
+ *
602
+ * Evaluated locally against the parsed claims — no network round-trip.
603
+ * Useful for disabling UI elements before attempting an op that would fail.
604
+ *
605
+ * @example
606
+ * ```ts
607
+ * if (!client.canRead("or:cart-42")) showLockIcon();
608
+ * ```
609
+ */
610
+ canRead(crdtId: string): boolean {
611
+ return evalCanRead(this.claims.permissions, crdtId, this.clientId);
612
+ }
613
+
614
+ /**
615
+ * Returns `true` if the token's permissions allow writing `crdtId`.
616
+ *
617
+ * Pass an optional `opMask` to check op-level access (V2 tokens only).
618
+ * Without `opMask`, checks key-level write access.
619
+ *
620
+ * @example
621
+ * ```ts
622
+ * import { OpMasks } from "meridian-sdk";
623
+ *
624
+ * if (!client.canWrite("or:cart-42", OpMasks.OR_ADD)) disableAddButton();
625
+ * if (!client.canWrite("gc:views")) disableIncrementButton();
626
+ * ```
627
+ */
628
+ canWrite(crdtId: string, opMask?: OpMask): boolean {
629
+ return evalCanWrite(this.claims.permissions, crdtId, this.clientId, opMask);
630
+ }
631
+
549
632
  close(): void {
550
633
  for (const unsub of this.handleUnsubs) unsub();
551
634
  this.handleUnsubs.length = 0;
552
635
  this.anyListeners.clear();
553
636
  this.deltaListeners.clear();
637
+ for (const queryId of this.liveQueries.keys()) {
638
+ this.transport.send({ UnsubscribeQuery: { query_id: queryId } });
639
+ }
640
+ this.liveQueries.clear();
554
641
  this.transport.close();
555
642
  }
556
643
 
@@ -559,6 +646,74 @@ export class MeridianClient {
559
646
  this.transport.reopen();
560
647
  }
561
648
 
649
+ /**
650
+ * Execute a cross-CRDT query against the namespace.
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * const result = await client.query({ from: "gc:views-*", aggregate: "sum" });
655
+ * console.log(result.value); // total view count
656
+ * ```
657
+ */
658
+ query(spec: QuerySpec): Promise<QueryResult> {
659
+ return Effect.runPromise(this.http.query(this.namespace, spec));
660
+ }
661
+
662
+ /**
663
+ * Subscribe to a live cross-CRDT query over WebSocket.
664
+ * The server re-executes the query and pushes a result whenever a matching CRDT changes.
665
+ *
666
+ * @example
667
+ * ```ts
668
+ * const handle = client.liveQuery({ from: "gc:views-*", aggregate: "sum" });
669
+ * handle.onResult(result => console.log("live total:", result.value));
670
+ * // later:
671
+ * handle.close();
672
+ * ```
673
+ */
674
+ liveQuery(spec: QuerySpec): LiveQueryHandle {
675
+ const queryId = `lq-${++this.liveQueryCounter}`;
676
+ const listeners = new Set<(result: LiveQueryResult) => void>();
677
+ this.liveQueries.set(queryId, { spec, listeners });
678
+ this.sendSubscribeQuery(queryId, spec);
679
+
680
+ return {
681
+ onResult: (listener) => {
682
+ listeners.add(listener);
683
+ return () => { listeners.delete(listener); };
684
+ },
685
+ close: () => {
686
+ this.transport.send({ UnsubscribeQuery: { query_id: queryId } });
687
+ this.liveQueries.delete(queryId);
688
+ },
689
+ };
690
+ }
691
+
692
+ private sendSubscribeQuery(queryId: string, spec: QuerySpec): void {
693
+ this.transport.send({
694
+ SubscribeQuery: {
695
+ query_id: queryId,
696
+ query: {
697
+ from: spec.from,
698
+ ...(spec.type !== undefined && { type: spec.type }),
699
+ aggregate: spec.aggregate,
700
+ ...(spec.where !== undefined && {
701
+ where: {
702
+ ...(spec.where.contains !== undefined && { contains: spec.where.contains }),
703
+ ...(spec.where.updatedAfter !== undefined && { updated_after: spec.where.updatedAfter }),
704
+ },
705
+ }),
706
+ },
707
+ },
708
+ });
709
+ }
710
+
711
+ private resubscribeLiveQueries(): void {
712
+ for (const [queryId, { spec }] of this.liveQueries) {
713
+ this.sendSubscribeQuery(queryId, spec);
714
+ }
715
+ }
716
+
562
717
  private notifyAnyChange(): void {
563
718
  for (const fn of this.anyListeners) fn();
564
719
  }
@@ -566,10 +721,27 @@ export class MeridianClient {
566
721
  private handleServerMsg(msg: ServerMsg): void {
567
722
  if ("AwarenessBroadcast" in msg) {
568
723
  const { client_id, key, data } = msg.AwarenessBroadcast;
724
+ // Dispatch RPC frames — these are AwarenessUpdates with a "__rpc__" key.
725
+ if (key === "__rpc__" && this.rpcListeners.size > 0) {
726
+ try {
727
+ const frame = msgpackDecode(data);
728
+ for (const fn of this.rpcListeners) fn(frame);
729
+ } catch { /* malformed frame — drop silently */ }
730
+ return;
731
+ }
569
732
  this.awHandles.get(key)?.applyBroadcast(client_id, data);
570
733
  return;
571
734
  }
572
735
 
736
+ if ("QueryResult" in msg) {
737
+ const { query_id, value, matched } = msg.QueryResult;
738
+ const entry = this.liveQueries.get(query_id);
739
+ if (entry) {
740
+ for (const fn of entry.listeners) fn({ value, matched });
741
+ }
742
+ return;
743
+ }
744
+
573
745
  if (!("Delta" in msg)) return;
574
746
  const { crdt_id, delta_bytes } = msg.Delta;
575
747
 
package/src/codec.ts CHANGED
@@ -39,6 +39,10 @@ export const uuidToBytes = (uuid: string): Uint8Array => {
39
39
 
40
40
  export const encodeVectorClock = (vc: VectorClock): Uint8Array => encode({ entries: vc });
41
41
 
42
+ const VectorClockWire = Schema.Struct({
43
+ entries: Schema.Record({ key: Schema.String, value: Schema.Number }),
44
+ });
45
+
42
46
  export const decodeVectorClock = (bytes: Uint8Array): Effect.Effect<VectorClock, CodecError> => {
43
47
  let raw: unknown;
44
48
  try {
@@ -47,12 +51,8 @@ export const decodeVectorClock = (bytes: Uint8Array): Effect.Effect<VectorClock,
47
51
  return Effect.fail(new CodecError({ message: "VectorClock msgpack decode failed", raw: bytes }));
48
52
  }
49
53
 
50
- const entries = raw !== null && typeof raw === "object" && "entries" in raw
51
- ? (raw as { entries: unknown }).entries
52
- : {};
53
- return Schema.decodeUnknown(Schema.Record({ key: Schema.String, value: Schema.Number }))(
54
- entries ?? {},
55
- ).pipe(
54
+ return Schema.decodeUnknown(VectorClockWire)(raw).pipe(
55
+ Effect.map((w) => w.entries),
56
56
  Effect.mapError((e) =>
57
57
  new CodecError({ message: `VectorClock schema validation failed: ${e.message}`, raw: bytes }),
58
58
  ),
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,
@@ -39,6 +40,8 @@ export type { WsTransportConfig, WsState } from "./transport/websocket.js";
39
40
 
40
41
  // Auth
41
42
  export { parseToken, parseAndValidateToken, checkTokenExpiry, tokenTtlMs } from "./auth/token.js";
43
+ export { canRead, canWrite, OpMasks } from "./auth/permissions.js";
44
+ export type { OpMask } from "./auth/permissions.js";
42
45
 
43
46
  // Errors (all Data.TaggedError — matchable with Effect.catchTag)
44
47
  export {
@@ -68,6 +71,9 @@ export {
68
71
  CrdtGetResponse,
69
72
  CrdtOpResponse,
70
73
  ErrorResponse,
74
+ QuerySpec,
75
+ QueryResult,
76
+ LiveQueryResult,
71
77
  } from "./schema.js";
72
78
  export type { TimestampMs, ClientId } from "./schema.js";
73
79
 
@@ -106,6 +112,10 @@ export type {
106
112
  MeridianAgentConfig,
107
113
  } from "./agents.js";
108
114
 
115
+ // RPC — type-safe WS events + responses (à la partyRPC)
116
+ export { createMeridianRpc } from "./rpc.js";
117
+ export type { MeridianRpc, MeridianRpcClient, MeridianRpcConfig, Unsubscribe } from "./rpc.js";
118
+
109
119
  // Effect Layer — dependency injection
110
120
  export { MeridianService, MeridianLive } from "./layer.js";
111
121
 
package/src/rpc.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Type-safe RPC layer for Meridian WebSocket + HTTP.
3
+ *
4
+ * Inspired by partyRPC — define your events and responses once, get fully
5
+ * typed `send` / `on` on the client with Effect Schema validation at every
6
+ * decode boundary.
7
+ *
8
+ * Custom messages piggyback on the existing `AwarenessUpdate` transport using
9
+ * a reserved `"__rpc__"` key. The server broadcasts them via the
10
+ * `AwarenessBroadcast` path — no server changes required.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Schema } from "effect";
15
+ * import { createMeridianRpc } from "meridian-sdk";
16
+ *
17
+ * const rpc = createMeridianRpc({
18
+ * events: {
19
+ * "cursor-move": Schema.Struct({ x: Schema.Number, y: Schema.Number }),
20
+ * "reaction": Schema.Struct({ emoji: Schema.String }),
21
+ * },
22
+ * responses: {
23
+ * "cursor-moved": Schema.Struct({ clientId: Schema.Number, x: Schema.Number, y: Schema.Number }),
24
+ * "reacted": Schema.Struct({ clientId: Schema.Number, emoji: Schema.String }),
25
+ * },
26
+ * });
27
+ *
28
+ * export type AppRpc = typeof rpc;
29
+ *
30
+ * const client = rpc.createClient(meridianClient);
31
+ *
32
+ * client.send("cursor-move", { x: 10, y: 20 }); // ✅
33
+ * client.send("cursor-move", { x: "oops" }); // ❌ compile error
34
+ *
35
+ * client.on("cursor-moved", (msg) => {
36
+ * console.log(msg.clientId, msg.x, msg.y); // ✅ no casts
37
+ * });
38
+ * ```
39
+ */
40
+
41
+ import { Schema, Effect, type ParseResult, pipe } from "effect";
42
+ import { encode } from "./codec.js";
43
+ import type { MeridianClient } from "./client.js";
44
+
45
+ type AnySchema = Schema.Schema<unknown, unknown, never>;
46
+ type SchemaMap = Record<string, AnySchema>;
47
+ type InferSchema<M extends SchemaMap, K extends keyof M> = Schema.Schema.Type<M[K]>;
48
+
49
+ /** Unsubscribe function returned by `on()`. */
50
+ export type Unsubscribe = () => void;
51
+
52
+ interface RpcFrame {
53
+ t: string;
54
+ p: unknown;
55
+ }
56
+
57
+ export interface MeridianRpcConfig<
58
+ Events extends SchemaMap,
59
+ Responses extends SchemaMap,
60
+ > {
61
+ /** Messages the client can **send** to the server. Key = event type, value = Effect Schema for the payload. */
62
+ events: Events;
63
+ /** Messages the server can **send** to the client. Key = response type, value = Effect Schema for the payload. */
64
+ responses: Responses;
65
+ }
66
+
67
+ export interface MeridianRpcClient<
68
+ Events extends SchemaMap,
69
+ Responses extends SchemaMap,
70
+ > {
71
+ /**
72
+ * Send a typed event to the server.
73
+ * Payload is validated against the schema before encoding.
74
+ */
75
+ send<K extends keyof Events & string>(
76
+ type: K,
77
+ payload: InferSchema<Events, K>,
78
+ ): Effect.Effect<void, ParseResult.ParseError>;
79
+
80
+ /**
81
+ * Subscribe to a typed response from the server.
82
+ * Invalid frames are dropped silently.
83
+ * Returns an unsubscribe function.
84
+ */
85
+ on<K extends keyof Responses & string>(
86
+ type: K,
87
+ listener: (payload: InferSchema<Responses, K>) => void,
88
+ ): Unsubscribe;
89
+ }
90
+
91
+ export interface MeridianRpc<
92
+ Events extends SchemaMap,
93
+ Responses extends SchemaMap,
94
+ > {
95
+ readonly events: Events;
96
+ readonly responses: Responses;
97
+ /** Bind this definition to a live `MeridianClient`. */
98
+ createClient(client: MeridianClient): MeridianRpcClient<Events, Responses>;
99
+ }
100
+
101
+ /**
102
+ * Define a type-safe RPC layer on top of a Meridian WebSocket connection.
103
+ */
104
+ export function createMeridianRpc<
105
+ Events extends SchemaMap,
106
+ Responses extends SchemaMap,
107
+ >(config: MeridianRpcConfig<Events, Responses>): MeridianRpc<Events, Responses> {
108
+ return {
109
+ events: config.events,
110
+ responses: config.responses,
111
+
112
+ createClient(client: MeridianClient): MeridianRpcClient<Events, Responses> {
113
+ const listeners = new Map<string, Set<(payload: unknown) => void>>();
114
+
115
+ client.onRpcFrame((raw: unknown) => {
116
+ const frame = raw as RpcFrame;
117
+ if (typeof frame?.t !== "string") return;
118
+ const schema = config.responses[frame.t];
119
+ if (!schema) return;
120
+
121
+ const parsed = Schema.decodeUnknownOption(schema)(frame.p);
122
+ if (parsed._tag === "None") return;
123
+
124
+ const typeListeners = listeners.get(frame.t);
125
+ if (typeListeners) {
126
+ for (const fn of typeListeners) fn(parsed.value);
127
+ }
128
+ });
129
+
130
+ return {
131
+ send<K extends keyof Events & string>(
132
+ type: K,
133
+ payload: InferSchema<Events, K>,
134
+ ): Effect.Effect<void, ParseResult.ParseError> {
135
+ const schema = config.events[type] as Events[K];
136
+ return pipe(
137
+ Schema.encode(schema)(payload),
138
+ Effect.andThen((validated) => {
139
+ const frame: RpcFrame = { t: type, p: validated };
140
+ client.sendRpcFrame(encode(frame));
141
+ }),
142
+ );
143
+ },
144
+
145
+ on<K extends keyof Responses & string>(
146
+ type: K,
147
+ listener: (payload: InferSchema<Responses, K>) => void,
148
+ ): Unsubscribe {
149
+ if (!listeners.has(type)) listeners.set(type, new Set());
150
+ listeners.get(type)?.add(listener as (p: unknown) => void);
151
+ return () => { listeners.get(type)?.delete(listener as (p: unknown) => void); };
152
+ },
153
+ };
154
+ },
155
+ };
156
+ }