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.
- package/CHANGELOG.md +14 -0
- package/README.md +99 -0
- package/dist/agents.d.ts.map +1 -1
- package/dist/agents.js +0 -6
- package/dist/agents.js.map +1 -1
- package/dist/auth/permissions.d.ts +45 -0
- package/dist/auth/permissions.d.ts.map +1 -0
- package/dist/auth/permissions.js +101 -0
- package/dist/auth/permissions.js.map +1 -0
- package/dist/client.d.ts +80 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +147 -2
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +4 -4
- package/dist/codec.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/rpc.d.ts +77 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +85 -0
- package/dist/rpc.js.map +1 -0
- package/dist/schema.d.ts +46 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +41 -0
- package/dist/schema.js.map +1 -1
- package/dist/transport/http.d.ts +21 -4
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +20 -1
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/websocket.d.ts +7 -0
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +14 -6
- package/dist/transport/websocket.js.map +1 -1
- package/dist/undo/UndoManager.d.ts.map +1 -1
- package/dist/undo/UndoManager.js +8 -6
- package/dist/undo/UndoManager.js.map +1 -1
- package/dist/utils/fractional.js +3 -3
- package/dist/utils/fractional.js.map +1 -1
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agents.ts +1 -17
- package/src/auth/permissions.ts +130 -0
- package/src/client.ts +174 -2
- package/src/codec.ts +6 -6
- package/src/index.ts +10 -0
- package/src/rpc.ts +156 -0
- package/src/schema.ts +57 -0
- package/src/transport/http.ts +32 -2
- package/src/transport/websocket.ts +15 -7
- package/src/undo/UndoManager.ts +7 -6
- package/src/utils/fractional.ts +3 -3
- package/src/validation/index.ts +1 -1
- package/test/integration.test.ts +0 -4
- 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
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
+
}
|