meridian-sdk 0.1.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 (81) hide show
  1. package/README.md +119 -0
  2. package/dist/auth/token.d.ts +27 -0
  3. package/dist/auth/token.d.ts.map +1 -0
  4. package/dist/auth/token.js +75 -0
  5. package/dist/auth/token.js.map +1 -0
  6. package/dist/client.d.ts +60 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +166 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/codec.d.ts +29 -0
  11. package/dist/codec.d.ts.map +1 -0
  12. package/dist/codec.js +72 -0
  13. package/dist/codec.js.map +1 -0
  14. package/dist/crdt/gcounter.d.ts +36 -0
  15. package/dist/crdt/gcounter.d.ts.map +1 -0
  16. package/dist/crdt/gcounter.js +76 -0
  17. package/dist/crdt/gcounter.js.map +1 -0
  18. package/dist/crdt/lwwregister.d.ts +42 -0
  19. package/dist/crdt/lwwregister.d.ts.map +1 -0
  20. package/dist/crdt/lwwregister.js +93 -0
  21. package/dist/crdt/lwwregister.js.map +1 -0
  22. package/dist/crdt/orset.d.ts +40 -0
  23. package/dist/crdt/orset.d.ts.map +1 -0
  24. package/dist/crdt/orset.js +115 -0
  25. package/dist/crdt/orset.js.map +1 -0
  26. package/dist/crdt/pncounter.d.ts +32 -0
  27. package/dist/crdt/pncounter.d.ts.map +1 -0
  28. package/dist/crdt/pncounter.js +77 -0
  29. package/dist/crdt/pncounter.js.map +1 -0
  30. package/dist/crdt/presence.d.ts +45 -0
  31. package/dist/crdt/presence.d.ts.map +1 -0
  32. package/dist/crdt/presence.js +133 -0
  33. package/dist/crdt/presence.js.map +1 -0
  34. package/dist/errors.d.ts +53 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/errors.js +30 -0
  37. package/dist/errors.js.map +1 -0
  38. package/dist/index.d.ts +18 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +20 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/schema.d.ts +118 -0
  43. package/dist/schema.d.ts.map +1 -0
  44. package/dist/schema.js +95 -0
  45. package/dist/schema.js.map +1 -0
  46. package/dist/sync/clock.d.ts +42 -0
  47. package/dist/sync/clock.d.ts.map +1 -0
  48. package/dist/sync/clock.js +95 -0
  49. package/dist/sync/clock.js.map +1 -0
  50. package/dist/sync/delta.d.ts +52 -0
  51. package/dist/sync/delta.d.ts.map +1 -0
  52. package/dist/sync/delta.js +35 -0
  53. package/dist/sync/delta.js.map +1 -0
  54. package/dist/transport/http.d.ts +38 -0
  55. package/dist/transport/http.d.ts.map +1 -0
  56. package/dist/transport/http.js +98 -0
  57. package/dist/transport/http.js.map +1 -0
  58. package/dist/transport/websocket.d.ts +60 -0
  59. package/dist/transport/websocket.d.ts.map +1 -0
  60. package/dist/transport/websocket.js +144 -0
  61. package/dist/transport/websocket.js.map +1 -0
  62. package/package.json +31 -0
  63. package/src/auth/token.ts +96 -0
  64. package/src/client.ts +197 -0
  65. package/src/codec.ts +92 -0
  66. package/src/crdt/gcounter.ts +101 -0
  67. package/src/crdt/lwwregister.ts +113 -0
  68. package/src/crdt/orset.ts +131 -0
  69. package/src/crdt/pncounter.ts +93 -0
  70. package/src/crdt/presence.ts +162 -0
  71. package/src/errors.ts +51 -0
  72. package/src/index.ts +59 -0
  73. package/src/schema.ts +144 -0
  74. package/src/sync/clock.ts +117 -0
  75. package/src/sync/delta.ts +104 -0
  76. package/src/transport/http.ts +150 -0
  77. package/src/transport/websocket.ts +189 -0
  78. package/test/crdt.test.ts +290 -0
  79. package/test/sync.test.ts +178 -0
  80. package/test/tsconfig.json +8 -0
  81. package/tsconfig.json +22 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Delta application helpers.
3
+ *
4
+ * The server sends deltas as opaque msgpack bytes inside `ServerMsg.Delta`.
5
+ * Each CRDT type decodes its own delta format. This module provides typed
6
+ * delta decoders that match the Rust server's serde output.
7
+ */
8
+
9
+ import { unpack } from "msgpackr";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // GCounter delta
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Sparse map of client_id → new count. Only changed entries are included. */
16
+ export interface GCounterDelta {
17
+ increments: Record<string, number>;
18
+ }
19
+
20
+ export function decodeGCounterDelta(bytes: Uint8Array): GCounterDelta {
21
+ const raw = unpack(bytes) as { increments?: Record<string, number> };
22
+ return { increments: raw.increments ?? {} };
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // PNCounter delta
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface PNCounterDelta {
30
+ p: GCounterDelta;
31
+ n: GCounterDelta;
32
+ }
33
+
34
+ export function decodePNCounterDelta(bytes: Uint8Array): PNCounterDelta {
35
+ const raw = unpack(bytes) as {
36
+ p?: { increments?: Record<string, number> };
37
+ n?: { increments?: Record<string, number> };
38
+ };
39
+ return {
40
+ p: { increments: raw.p?.increments ?? {} },
41
+ n: { increments: raw.n?.increments ?? {} },
42
+ };
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // ORSet delta
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface ORSetDelta {
50
+ /** element → set of add-tags (UUIDs as strings) */
51
+ added: Record<string, string[]>;
52
+ /** element → set of removed tags */
53
+ removed: Record<string, string[]>;
54
+ }
55
+
56
+ export function decodeORSetDelta(bytes: Uint8Array): ORSetDelta {
57
+ const raw = unpack(bytes) as {
58
+ added?: Record<string, string[]>;
59
+ removed?: Record<string, string[]>;
60
+ };
61
+ return {
62
+ added: raw.added ?? {},
63
+ removed: raw.removed ?? {},
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // LWW Register delta
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export interface LwwEntry {
72
+ value: unknown;
73
+ hlc: { wall_ms: number; logical: number; node_id: number };
74
+ author: number;
75
+ }
76
+
77
+ export interface LwwDelta {
78
+ entry: LwwEntry | null;
79
+ }
80
+
81
+ export function decodeLwwDelta(bytes: Uint8Array): LwwDelta {
82
+ const raw = unpack(bytes) as { entry?: LwwEntry | null };
83
+ return { entry: raw.entry ?? null };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Presence delta
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface PresenceEntryDelta {
91
+ data: unknown;
92
+ hlc: { wall_ms: number; logical: number; node_id: number };
93
+ ttl_ms: number;
94
+ }
95
+
96
+ export interface PresenceDelta {
97
+ /** client_id → entry (null = removed/tombstone) */
98
+ changes: Record<string, PresenceEntryDelta | null>;
99
+ }
100
+
101
+ export function decodePresenceDelta(bytes: Uint8Array): PresenceDelta {
102
+ const raw = unpack(bytes) as { changes?: Record<string, PresenceEntryDelta | null> };
103
+ return { changes: raw.changes ?? {} };
104
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * HTTP transport — REST API client for Meridian.
3
+ *
4
+ * All methods return Effect<T, HttpError | NetworkError> so callers
5
+ * can handle network failures and server errors as typed values.
6
+ */
7
+
8
+ import { encode, decode } from "../codec.js";
9
+ import { Effect, Schema } from "effect";
10
+ import { HttpError, NetworkError } from "../errors.js";
11
+ import {
12
+ CrdtGetResponse,
13
+ CrdtOpResponse,
14
+ TokenIssueResponse,
15
+ ErrorResponse,
16
+ VectorClock,
17
+ } from "../schema.js";
18
+ import type { Permissions } from "../schema.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Config
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface HttpClientConfig {
25
+ /** Base URL of the Meridian server, e.g. "http://localhost:3000" */
26
+ baseUrl: string;
27
+ /** Bearer token. Can be updated after construction (e.g. after refresh). */
28
+ token: string;
29
+ /** Request timeout in ms. Default: 10_000 */
30
+ timeoutMs?: number;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // HttpClient
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export class HttpClient {
38
+ private readonly baseUrl: string;
39
+ private readonly timeoutMs: number;
40
+ token: string;
41
+
42
+ constructor(config: HttpClientConfig) {
43
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
44
+ this.token = config.token;
45
+ this.timeoutMs = config.timeoutMs ?? 10_000;
46
+ }
47
+
48
+ // ---- CRDT REST ----
49
+
50
+ /** GET /v1/namespaces/:ns/crdts/:id */
51
+ getCrdt(ns: string, id: string): Effect.Effect<CrdtGetResponse, HttpError | NetworkError> {
52
+ return this.request(CrdtGetResponse, "GET", `/v1/namespaces/${ns}/crdts/${id}`);
53
+ }
54
+
55
+ /** POST /v1/namespaces/:ns/crdts/:id/ops */
56
+ postOp(ns: string, id: string, op: unknown): Effect.Effect<CrdtOpResponse, HttpError | NetworkError> {
57
+ return this.request(CrdtOpResponse, "POST", `/v1/namespaces/${ns}/crdts/${id}/ops`, op);
58
+ }
59
+
60
+ /** GET /v1/namespaces/:ns/crdts/:id/sync?since=<base64url(msgpack(vc))> */
61
+ syncCrdt(
62
+ ns: string,
63
+ id: string,
64
+ sinceVc?: VectorClock,
65
+ ): Effect.Effect<CrdtGetResponse, HttpError | NetworkError> {
66
+ let path = `/v1/namespaces/${ns}/crdts/${id}/sync`;
67
+ if (sinceVc && Object.keys(sinceVc).length > 0) {
68
+ const bytes = encode({ entries: sinceVc });
69
+ path += `?since=${base64urlEncode(bytes)}`;
70
+ }
71
+ return this.request(CrdtGetResponse, "GET", path);
72
+ }
73
+
74
+ /** POST /v1/namespaces/:ns/tokens (admin-only) */
75
+ issueToken(
76
+ ns: string,
77
+ opts: { client_id: number; ttl_ms: number; permissions: Permissions },
78
+ ): Effect.Effect<TokenIssueResponse, HttpError | NetworkError> {
79
+ return this.request(TokenIssueResponse, "POST", `/v1/namespaces/${ns}/tokens`, opts);
80
+ }
81
+
82
+ // ---- Internal ----
83
+
84
+ private request<A, I>(
85
+ responseSchema: Schema.Schema<A, I>,
86
+ method: string,
87
+ path: string,
88
+ body?: unknown,
89
+ ): Effect.Effect<A, HttpError | NetworkError> {
90
+ const url = `${this.baseUrl}${path}`;
91
+ const headers: Record<string, string> = {
92
+ Authorization: `Bearer ${this.token}`,
93
+ Accept: "application/msgpack",
94
+ };
95
+
96
+ let bodyBytes: Uint8Array | undefined;
97
+ if (body !== undefined) {
98
+ bodyBytes = encode(body);
99
+ headers["Content-Type"] = "application/msgpack";
100
+ }
101
+
102
+ const fetchEffect = Effect.tryPromise({
103
+ try: async () => {
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
106
+ try {
107
+ const response = await fetch(url, { method, headers, body: bodyBytes, signal: controller.signal });
108
+ const bytes = new Uint8Array(await response.arrayBuffer());
109
+ return { response, bytes };
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ },
114
+ catch: (e) =>
115
+ new NetworkError({ message: e instanceof Error ? e.message : "fetch failed", cause: e }),
116
+ });
117
+
118
+ return fetchEffect.pipe(
119
+ Effect.flatMap(({ response, bytes }): Effect.Effect<A, HttpError | NetworkError> => {
120
+ if (!response.ok) {
121
+ let errBody: ErrorResponse;
122
+ try {
123
+ errBody = Schema.decodeUnknownSync(ErrorResponse)(decode(bytes));
124
+ } catch {
125
+ errBody = { error: "unknown", message: `HTTP ${response.status}` };
126
+ }
127
+ return Effect.fail(new HttpError({ status: response.status, body: errBody }));
128
+ }
129
+ const raw = decode(bytes);
130
+ return Schema.decodeUnknown(responseSchema)(raw).pipe(
131
+ Effect.mapError((e) =>
132
+ new NetworkError({ message: `Response schema validation failed: ${e.message}` }),
133
+ ),
134
+ );
135
+ }),
136
+ );
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // base64url (no-padding) encoder
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function base64urlEncode(bytes: Uint8Array): string {
145
+ let binary = "";
146
+ for (const b of bytes) {
147
+ binary += String.fromCharCode(b);
148
+ }
149
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
150
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * WebSocket transport — reconnect FSM + Sync on reconnect.
3
+ *
4
+ * States:
5
+ * DISCONNECTED → CONNECTING → AUTHENTICATING → CONNECTED → CLOSING
6
+ *
7
+ * On reconnect: re-subscribes to all known CRDTs and sends Sync(localVectorClock)
8
+ * so the server can push missed deltas.
9
+ *
10
+ * Backoff: 100ms → 200ms → 400ms → … → 30s (±20% jitter).
11
+ */
12
+
13
+ import { Effect } from "effect";
14
+ import { encodeClientMsg, decodeServerMsg, encodeVectorClock } from "../codec.js";
15
+ import type { ServerMsg, VectorClock } from "../schema.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export type WsState =
22
+ | "DISCONNECTED"
23
+ | "CONNECTING"
24
+ | "CONNECTED"
25
+ | "CLOSING";
26
+
27
+ export interface WsTransportConfig {
28
+ /** Full WebSocket URL, e.g. "ws://localhost:3000/v1/namespaces/my-room/connect" */
29
+ url: string;
30
+ /** Bearer token passed as ?token= query param (WS can't set headers). */
31
+ token: string;
32
+ /** Called whenever a ServerMsg arrives. */
33
+ onMessage: (msg: ServerMsg) => void;
34
+ /** Called on state transitions. */
35
+ onStateChange?: (state: WsState) => void;
36
+ /** Maximum reconnect delay in ms. Default: 30_000 */
37
+ maxBackoffMs?: number;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // WsTransport
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export class WsTransport {
45
+ private readonly config: WsTransportConfig;
46
+ private readonly maxBackoffMs: number;
47
+
48
+ private ws: WebSocket | null = null;
49
+ private state: WsState = "DISCONNECTED";
50
+ private backoffMs = 100;
51
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
52
+ private closed = false;
53
+
54
+ /** CRDTs to re-subscribe on reconnect: crdt_id → last known VectorClock */
55
+ private readonly subscriptions = new Map<string, VectorClock>();
56
+
57
+ constructor(config: WsTransportConfig) {
58
+ this.config = config;
59
+ this.maxBackoffMs = config.maxBackoffMs ?? 30_000;
60
+ }
61
+
62
+ // ---- Public API ----
63
+
64
+ connect(): void {
65
+ if (this.closed) return;
66
+ this.closed = false;
67
+ this.doConnect();
68
+ }
69
+
70
+ /** Gracefully close — will not reconnect. */
71
+ close(): void {
72
+ this.closed = true;
73
+ this.clearReconnectTimer();
74
+ this.transitionTo("CLOSING");
75
+ this.ws?.close(1000, "client close");
76
+ }
77
+
78
+ /**
79
+ * Subscribe to a CRDT's deltas.
80
+ * If already connected, sends Subscribe immediately.
81
+ * On reconnect, the subscription is re-sent automatically.
82
+ */
83
+ subscribe(crdtId: string, sinceVc: VectorClock = {}): void {
84
+ this.subscriptions.set(crdtId, sinceVc);
85
+ if (this.state === "CONNECTED") {
86
+ this.sendSubscribe(crdtId, sinceVc);
87
+ }
88
+ }
89
+
90
+ /** Update the local vector clock for a CRDT (used for reconnect Sync). */
91
+ updateClock(crdtId: string, vc: VectorClock): void {
92
+ this.subscriptions.set(crdtId, vc);
93
+ }
94
+
95
+ /** Send a raw ClientMsg. Throws if not connected. */
96
+ send(msg: Parameters<typeof encodeClientMsg>[0]): void {
97
+ if (this.state !== "CONNECTED" || this.ws === null) {
98
+ throw new Error("WsTransport: not connected");
99
+ }
100
+ this.ws.send(encodeClientMsg(msg));
101
+ }
102
+
103
+ get currentState(): WsState {
104
+ return this.state;
105
+ }
106
+
107
+ // ---- FSM internals ----
108
+
109
+ private doConnect(): void {
110
+ if (this.closed) return;
111
+ this.transitionTo("CONNECTING");
112
+
113
+ const url = `${this.config.url}${this.config.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.config.token)}`;
114
+ const ws = new WebSocket(url);
115
+ ws.binaryType = "arraybuffer";
116
+ this.ws = ws;
117
+
118
+ ws.addEventListener("open", () => {
119
+ if (ws !== this.ws) return; // stale socket
120
+ this.backoffMs = 100; // reset backoff on successful connect
121
+ this.transitionTo("CONNECTED");
122
+ this.resubscribeAll();
123
+ });
124
+
125
+ ws.addEventListener("message", (event: MessageEvent) => {
126
+ if (ws !== this.ws) return;
127
+ const bytes = new Uint8Array(event.data as ArrayBuffer);
128
+ Effect.runPromise(decodeServerMsg(bytes)).then(
129
+ (msg) => { this.config.onMessage(msg); },
130
+ (e) => { console.warn("[meridian] failed to decode server message", e); },
131
+ );
132
+ });
133
+
134
+ ws.addEventListener("close", () => {
135
+ if (ws !== this.ws) return;
136
+ this.ws = null;
137
+ if (!this.closed) {
138
+ this.transitionTo("DISCONNECTED");
139
+ this.scheduleReconnect();
140
+ }
141
+ });
142
+
143
+ ws.addEventListener("error", () => {
144
+ // The "close" event fires right after — let that handle reconnect.
145
+ });
146
+ }
147
+
148
+ private scheduleReconnect(): void {
149
+ if (this.closed) return;
150
+ const jitter = this.backoffMs * 0.2 * (Math.random() * 2 - 1); // ±20%
151
+ const delay = Math.round(this.backoffMs + jitter);
152
+ this.reconnectTimer = setTimeout(() => {
153
+ this.reconnectTimer = null;
154
+ this.doConnect();
155
+ }, delay);
156
+ this.backoffMs = Math.min(this.backoffMs * 2, this.maxBackoffMs);
157
+ }
158
+
159
+ private clearReconnectTimer(): void {
160
+ if (this.reconnectTimer !== null) {
161
+ clearTimeout(this.reconnectTimer);
162
+ this.reconnectTimer = null;
163
+ }
164
+ }
165
+
166
+ private transitionTo(next: WsState): void {
167
+ if (this.state === next) return;
168
+ this.state = next;
169
+ this.config.onStateChange?.(next);
170
+ }
171
+
172
+ /** On reconnect: re-subscribe and send Sync for each known CRDT. */
173
+ private resubscribeAll(): void {
174
+ for (const [crdtId, vc] of this.subscriptions) {
175
+ this.sendSubscribe(crdtId, vc);
176
+ }
177
+ }
178
+
179
+ private sendSubscribe(crdtId: string, vc: VectorClock): void {
180
+ if (this.ws === null || this.state !== "CONNECTED") return;
181
+
182
+ // First subscribe so the server starts pushing future deltas
183
+ this.ws.send(encodeClientMsg({ Subscribe: { crdt_id: crdtId } }));
184
+
185
+ // Then sync to get missed deltas since our last known VC
186
+ const vcBytes = encodeVectorClock(vc);
187
+ this.ws.send(encodeClientMsg({ Sync: { crdt_id: crdtId, since_vc: vcBytes } }));
188
+ }
189
+ }