meridian-sdk 0.2.1 → 0.2.3
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/biome.json +4 -0
- package/dist/auth/token.d.ts +0 -19
- package/dist/auth/token.d.ts.map +1 -1
- package/dist/auth/token.js +6 -31
- package/dist/auth/token.js.map +1 -1
- package/dist/client.d.ts +139 -23
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +165 -52
- package/dist/client.js.map +1 -1
- package/dist/codec.d.ts +7 -35
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +13 -65
- package/dist/codec.js.map +1 -1
- package/dist/constants.d.ts +7 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +7 -0
- package/dist/constants.js.map +1 -0
- package/dist/crdt/gcounter.d.ts +18 -9
- package/dist/crdt/gcounter.d.ts.map +1 -1
- package/dist/crdt/gcounter.js +16 -13
- package/dist/crdt/gcounter.js.map +1 -1
- package/dist/crdt/lwwregister.d.ts +24 -11
- package/dist/crdt/lwwregister.d.ts.map +1 -1
- package/dist/crdt/lwwregister.js +25 -19
- package/dist/crdt/lwwregister.js.map +1 -1
- package/dist/crdt/orset.d.ts +25 -13
- package/dist/crdt/orset.d.ts.map +1 -1
- package/dist/crdt/orset.js +31 -23
- package/dist/crdt/orset.js.map +1 -1
- package/dist/crdt/pncounter.d.ts +22 -4
- package/dist/crdt/pncounter.d.ts.map +1 -1
- package/dist/crdt/pncounter.js +28 -14
- package/dist/crdt/pncounter.js.map +1 -1
- package/dist/crdt/presence.d.ts +33 -13
- package/dist/crdt/presence.d.ts.map +1 -1
- package/dist/crdt/presence.js +36 -20
- package/dist/crdt/presence.js.map +1 -1
- package/dist/errors.d.ts +0 -4
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +0 -16
- package/dist/errors.js.map +1 -1
- package/dist/schema.d.ts +3 -9
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +3 -34
- package/dist/schema.js.map +1 -1
- package/dist/sync/clock.d.ts +1 -20
- package/dist/sync/clock.d.ts.map +1 -1
- package/dist/sync/clock.js +20 -46
- package/dist/sync/clock.js.map +1 -1
- package/dist/sync/delta.d.ts +5 -22
- package/dist/sync/delta.d.ts.map +1 -1
- package/dist/sync/delta.js +18 -26
- package/dist/sync/delta.js.map +1 -1
- package/dist/transport/http.d.ts +1 -14
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +3 -21
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/websocket.d.ts +0 -27
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +9 -37
- package/dist/transport/websocket.js.map +1 -1
- package/dist/utils/to-hex.d.ts +2 -0
- package/dist/utils/to-hex.d.ts.map +1 -0
- package/dist/utils/to-hex.js +2 -0
- package/dist/utils/to-hex.js.map +1 -0
- package/package.json +6 -3
- package/src/auth/token.ts +6 -34
- package/src/client.ts +165 -65
- package/src/codec.ts +13 -71
- package/src/constants.ts +6 -0
- package/src/crdt/gcounter.ts +18 -20
- package/src/crdt/lwwregister.ts +27 -26
- package/src/crdt/orset.ts +32 -29
- package/src/crdt/pncounter.ts +30 -21
- package/src/crdt/presence.ts +37 -26
- package/src/errors.ts +0 -21
- package/src/schema.ts +3 -44
- package/src/sync/clock.ts +18 -50
- package/src/sync/delta.ts +20 -58
- package/src/transport/http.ts +3 -33
- package/src/transport/websocket.ts +15 -52
- package/src/utils/to-hex.ts +1 -0
- package/test/integration.test.ts +2 -3
- package/test/sync.test.ts +1 -2
|
@@ -1,63 +1,40 @@
|
|
|
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
1
|
import { Effect } from "effect";
|
|
13
2
|
import { encodeClientMsg, decodeServerMsg, encodeVectorClock } from "../codec.js";
|
|
14
|
-
|
|
15
|
-
// WsTransport
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
3
|
+
import { BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_MULTIPLIER, JITTER_MULTIPLIER, DEFAULT_TIMEOUT_MS, } from "../constants.js";
|
|
17
4
|
export class WsTransport {
|
|
18
5
|
config;
|
|
19
6
|
maxBackoffMs;
|
|
20
7
|
ws = null;
|
|
21
8
|
state = "DISCONNECTED";
|
|
22
|
-
backoffMs =
|
|
9
|
+
backoffMs = BACKOFF_INITIAL_MS;
|
|
23
10
|
reconnectTimer = null;
|
|
24
11
|
closed = false;
|
|
25
|
-
/** CRDTs to re-subscribe on reconnect: crdt_id → last known VectorClock */
|
|
26
12
|
subscriptions = new Map();
|
|
27
13
|
constructor(config) {
|
|
28
14
|
this.config = config;
|
|
29
|
-
this.maxBackoffMs = config.maxBackoffMs ??
|
|
15
|
+
this.maxBackoffMs = config.maxBackoffMs ?? BACKOFF_MAX_MS;
|
|
30
16
|
}
|
|
31
|
-
// ---- Public API ----
|
|
32
17
|
connect() {
|
|
33
18
|
if (this.closed)
|
|
34
19
|
return;
|
|
35
20
|
this.closed = false;
|
|
36
21
|
this.doConnect();
|
|
37
22
|
}
|
|
38
|
-
/** Gracefully close — will not reconnect. */
|
|
39
23
|
close() {
|
|
40
24
|
this.closed = true;
|
|
41
25
|
this.clearReconnectTimer();
|
|
42
26
|
this.transitionTo("CLOSING");
|
|
43
27
|
this.ws?.close(1000, "client close");
|
|
44
28
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Subscribe to a CRDT's deltas.
|
|
47
|
-
* If already connected, sends Subscribe immediately.
|
|
48
|
-
* On reconnect, the subscription is re-sent automatically.
|
|
49
|
-
*/
|
|
50
29
|
subscribe(crdtId, sinceVc = {}) {
|
|
51
30
|
this.subscriptions.set(crdtId, sinceVc);
|
|
52
31
|
if (this.state === "CONNECTED") {
|
|
53
32
|
this.sendSubscribe(crdtId, sinceVc);
|
|
54
33
|
}
|
|
55
34
|
}
|
|
56
|
-
/** Update the local vector clock for a CRDT (used for reconnect Sync). */
|
|
57
35
|
updateClock(crdtId, vc) {
|
|
58
36
|
this.subscriptions.set(crdtId, vc);
|
|
59
37
|
}
|
|
60
|
-
/** Send a raw ClientMsg. Throws if not connected. */
|
|
61
38
|
send(msg) {
|
|
62
39
|
if (this.state !== "CONNECTED" || this.ws === null) {
|
|
63
40
|
throw new Error("WsTransport: not connected");
|
|
@@ -67,8 +44,7 @@ export class WsTransport {
|
|
|
67
44
|
get currentState() {
|
|
68
45
|
return this.state;
|
|
69
46
|
}
|
|
70
|
-
|
|
71
|
-
waitForConnected(timeoutMs = 5_000) {
|
|
47
|
+
waitForConnected(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
72
48
|
if (this.state === "CONNECTED")
|
|
73
49
|
return Promise.resolve();
|
|
74
50
|
return new Promise((resolve, reject) => {
|
|
@@ -95,7 +71,6 @@ export class WsTransport {
|
|
|
95
71
|
};
|
|
96
72
|
});
|
|
97
73
|
}
|
|
98
|
-
// ---- FSM internals ----
|
|
99
74
|
doConnect() {
|
|
100
75
|
if (this.closed)
|
|
101
76
|
return;
|
|
@@ -106,8 +81,8 @@ export class WsTransport {
|
|
|
106
81
|
this.ws = ws;
|
|
107
82
|
ws.addEventListener("open", () => {
|
|
108
83
|
if (ws !== this.ws)
|
|
109
|
-
return;
|
|
110
|
-
this.backoffMs =
|
|
84
|
+
return;
|
|
85
|
+
this.backoffMs = BACKOFF_INITIAL_MS;
|
|
111
86
|
this.transitionTo("CONNECTED");
|
|
112
87
|
this.resubscribeAll();
|
|
113
88
|
});
|
|
@@ -127,19 +102,19 @@ export class WsTransport {
|
|
|
127
102
|
}
|
|
128
103
|
});
|
|
129
104
|
ws.addEventListener("error", () => {
|
|
130
|
-
// The "close" event fires right after — let that
|
|
105
|
+
// HACK: The "close" event fires right after an error — let that handler drive reconnect logic.
|
|
131
106
|
});
|
|
132
107
|
}
|
|
133
108
|
scheduleReconnect() {
|
|
134
109
|
if (this.closed)
|
|
135
110
|
return;
|
|
136
|
-
const jitter = this.backoffMs *
|
|
111
|
+
const jitter = this.backoffMs * JITTER_MULTIPLIER * (Math.random() * 2 - 1);
|
|
137
112
|
const delay = Math.round(this.backoffMs + jitter);
|
|
138
113
|
this.reconnectTimer = setTimeout(() => {
|
|
139
114
|
this.reconnectTimer = null;
|
|
140
115
|
this.doConnect();
|
|
141
116
|
}, delay);
|
|
142
|
-
this.backoffMs = Math.min(this.backoffMs *
|
|
117
|
+
this.backoffMs = Math.min(this.backoffMs * BACKOFF_MULTIPLIER, this.maxBackoffMs);
|
|
143
118
|
}
|
|
144
119
|
clearReconnectTimer() {
|
|
145
120
|
if (this.reconnectTimer !== null) {
|
|
@@ -153,7 +128,6 @@ export class WsTransport {
|
|
|
153
128
|
this.state = next;
|
|
154
129
|
this.config.onStateChange?.(next);
|
|
155
130
|
}
|
|
156
|
-
/** On reconnect: re-subscribe and send Sync for each known CRDT. */
|
|
157
131
|
resubscribeAll() {
|
|
158
132
|
for (const [crdtId, vc] of this.subscriptions) {
|
|
159
133
|
this.sendSubscribe(crdtId, vc);
|
|
@@ -162,9 +136,7 @@ export class WsTransport {
|
|
|
162
136
|
sendSubscribe(crdtId, vc) {
|
|
163
137
|
if (this.ws === null || this.state !== "CONNECTED")
|
|
164
138
|
return;
|
|
165
|
-
// First subscribe so the server starts pushing future deltas
|
|
166
139
|
this.ws.send(encodeClientMsg({ Subscribe: { crdt_id: crdtId } }));
|
|
167
|
-
// Then sync to get missed deltas since our last known VC
|
|
168
140
|
const vcBytes = encodeVectorClock(vc);
|
|
169
141
|
this.ws.send(encodeClientMsg({ Sync: { crdt_id: crdtId, since_vc: vcBytes } }));
|
|
170
142
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.js","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"websocket.js","sourceRoot":"","sources":["../../src/transport/websocket.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-hex.d.ts","sourceRoot":"","sources":["../../src/utils/to-hex.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,KAAK,GAAI,OAAO,MAAM,KAAG,MAA6C,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"to-hex.js","sourceRoot":"","sources":["../../src/utils/to-hex.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "meridian-sdk",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "TypeScript SDK for Meridian CRDT server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,15 +15,18 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"test": "bun test",
|
|
17
17
|
"test:watch": "bun test --watch",
|
|
18
|
-
"typecheck": "tsc --noEmit"
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"lint": "biome lint ./src",
|
|
20
|
+
"format": "biome format --write ./src"
|
|
19
21
|
},
|
|
20
22
|
"devDependencies": {
|
|
23
|
+
"@biomejs/biome": "^2.4.7",
|
|
21
24
|
"@types/bun": "latest",
|
|
22
25
|
"typescript": "^5.7.0"
|
|
23
26
|
},
|
|
24
27
|
"dependencies": {
|
|
25
28
|
"effect": "^3.19.0",
|
|
26
|
-
"
|
|
29
|
+
"@msgpack/msgpack": "^3.0.0"
|
|
27
30
|
},
|
|
28
31
|
"peerDependencies": {
|
|
29
32
|
"typescript": ">=5.0.0"
|
package/src/auth/token.ts
CHANGED
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
* Token parsing and expiry check (client-side only — no signature verification).
|
|
3
|
-
*
|
|
4
|
-
* Wire format: `base64url_no_pad(msgpack(TokenClaims)) + "." + base64url_no_pad(sig[64B])`
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { unpack } from "msgpackr";
|
|
1
|
+
import { decode } from "@msgpack/msgpack";
|
|
8
2
|
import { Effect, Schema } from "effect";
|
|
9
3
|
import { TokenParseError, TokenExpiredError } from "../errors.js";
|
|
10
4
|
import { TokenClaims } from "../schema.js";
|
|
5
|
+
import { TOKEN_SKEW_MS } from "../constants.js";
|
|
11
6
|
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Parse
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Parse a Meridian token string and return the decoded claims.
|
|
18
|
-
* Returns Effect<TokenClaims, TokenParseError>.
|
|
19
|
-
* Does NOT verify the ed25519 signature — the server enforces that.
|
|
20
|
-
*/
|
|
21
7
|
export const parseToken = (token: string): Effect.Effect<TokenClaims, TokenParseError> =>
|
|
22
8
|
Effect.gen(function* () {
|
|
23
9
|
const dotIndex = token.indexOf(".");
|
|
@@ -38,7 +24,7 @@ export const parseToken = (token: string): Effect.Effect<TokenClaims, TokenParse
|
|
|
38
24
|
|
|
39
25
|
let raw: unknown;
|
|
40
26
|
try {
|
|
41
|
-
raw =
|
|
27
|
+
raw = decode(bytes);
|
|
42
28
|
} catch {
|
|
43
29
|
yield* Effect.fail(new TokenParseError({ message: "Invalid token format: msgpack decode failed" }));
|
|
44
30
|
return undefined as never;
|
|
@@ -51,39 +37,25 @@ export const parseToken = (token: string): Effect.Effect<TokenClaims, TokenParse
|
|
|
51
37
|
);
|
|
52
38
|
});
|
|
53
39
|
|
|
54
|
-
/**
|
|
55
|
-
* Check token expiry. Returns Effect<TokenClaims, TokenExpiredError>.
|
|
56
|
-
* Clock-skew tolerance: ±5s.
|
|
57
|
-
*/
|
|
58
40
|
export const checkTokenExpiry = (
|
|
59
41
|
claims: TokenClaims,
|
|
60
42
|
nowMs = Date.now(),
|
|
61
43
|
): Effect.Effect<TokenClaims, TokenExpiredError> => {
|
|
62
|
-
|
|
63
|
-
if (nowMs >= claims.expires_at + SKEW_MS) {
|
|
44
|
+
if (nowMs >= claims.expires_at + TOKEN_SKEW_MS) {
|
|
64
45
|
return Effect.fail(new TokenExpiredError({ expiredAt: claims.expires_at }));
|
|
65
46
|
}
|
|
66
47
|
return Effect.succeed(claims);
|
|
67
48
|
};
|
|
68
49
|
|
|
69
|
-
/**
|
|
70
|
-
* Parse and check expiry in one step.
|
|
71
|
-
* Returns Effect<TokenClaims, TokenParseError | TokenExpiredError>.
|
|
72
|
-
*/
|
|
73
50
|
export const parseAndValidateToken = (
|
|
74
51
|
token: string,
|
|
75
52
|
): Effect.Effect<TokenClaims, TokenParseError | TokenExpiredError> =>
|
|
76
53
|
parseToken(token).pipe(Effect.flatMap(checkTokenExpiry));
|
|
77
54
|
|
|
78
|
-
/** Returns milliseconds until the token expires (negative if already expired). */
|
|
79
55
|
export const tokenTtlMs = (claims: TokenClaims, nowMs = Date.now()): number =>
|
|
80
56
|
claims.expires_at - nowMs;
|
|
81
57
|
|
|
82
|
-
|
|
83
|
-
// Base64url (no-padding) decoder — no external dep
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
function base64urlDecode(input: string): Uint8Array {
|
|
58
|
+
const base64urlDecode = (input: string): Uint8Array => {
|
|
87
59
|
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
88
60
|
const padLen = (4 - (padded.length % 4)) % 4;
|
|
89
61
|
const b64 = padded + "=".repeat(padLen);
|
|
@@ -93,4 +65,4 @@ function base64urlDecode(input: string): Uint8Array {
|
|
|
93
65
|
bytes[i] = binary.charCodeAt(i);
|
|
94
66
|
}
|
|
95
67
|
return bytes;
|
|
96
|
-
}
|
|
68
|
+
};
|
package/src/client.ts
CHANGED
|
@@ -1,20 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* MeridianClient — top-level SDK entry point.
|
|
3
|
-
*
|
|
4
|
-
* Use `MeridianClient.create(config)` (returns Effect) to parse the token
|
|
5
|
-
* and validate it before connecting.
|
|
6
|
-
*
|
|
7
|
-
* ```ts
|
|
8
|
-
* const client = await Effect.runPromise(
|
|
9
|
-
* MeridianClient.create({ url: "ws://localhost:3000", namespace: "room", token })
|
|
10
|
-
* );
|
|
11
|
-
* const counter = client.gcounter("gc:page-views");
|
|
12
|
-
* counter.increment();
|
|
13
|
-
* counter.onChange(v => console.log("views:", v));
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { Effect, Schema } from "effect";
|
|
1
|
+
import { Effect, type Schema } from "effect";
|
|
18
2
|
import { WsTransport } from "./transport/websocket.js";
|
|
19
3
|
import { HttpClient } from "./transport/http.js";
|
|
20
4
|
import { GCounterHandle } from "./crdt/gcounter.js";
|
|
@@ -33,25 +17,39 @@ import { parseAndValidateToken } from "./auth/token.js";
|
|
|
33
17
|
import type { ServerMsg, TokenClaims } from "./schema.js";
|
|
34
18
|
import type { TokenParseError, TokenExpiredError } from "./errors.js";
|
|
35
19
|
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Config
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
20
|
export interface MeridianClientConfig {
|
|
41
|
-
/** Base URL of the Meridian server, e.g. "http://localhost:3000" */
|
|
42
21
|
url: string;
|
|
43
|
-
/** Namespace to connect to. */
|
|
44
22
|
namespace: string;
|
|
45
|
-
/** Meridian token for this namespace. */
|
|
46
23
|
token: string;
|
|
47
|
-
/** If true, open the WebSocket immediately. Default: true */
|
|
48
24
|
autoConnect?: boolean;
|
|
49
25
|
}
|
|
50
26
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The main entry point for the Meridian real-time CRDT SDK.
|
|
29
|
+
*
|
|
30
|
+
* Create an instance with the static `MeridianClient.create()` factory, then
|
|
31
|
+
* use the handle methods to obtain typed CRDT handles that sync automatically
|
|
32
|
+
* over WebSocket.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import { Effect } from 'effect';
|
|
37
|
+
* import { MeridianClient } from 'meridian-sdk';
|
|
38
|
+
*
|
|
39
|
+
* const client = await Effect.runPromise(
|
|
40
|
+
* MeridianClient.create({
|
|
41
|
+
* url: 'wss://example.com',
|
|
42
|
+
* namespace: 'my-app',
|
|
43
|
+
* token: '<JWT>',
|
|
44
|
+
* })
|
|
45
|
+
* );
|
|
46
|
+
*
|
|
47
|
+
* const counter = client.gcounter('visitors');
|
|
48
|
+
* counter.increment();
|
|
49
|
+
*
|
|
50
|
+
* client.close();
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
55
53
|
export class MeridianClient {
|
|
56
54
|
readonly namespace: string;
|
|
57
55
|
readonly clientId: number;
|
|
@@ -60,8 +58,7 @@ export class MeridianClient {
|
|
|
60
58
|
private readonly transport: WsTransport;
|
|
61
59
|
readonly http: HttpClient;
|
|
62
60
|
|
|
63
|
-
//
|
|
64
|
-
// factories restore them via typed get+cast on retrieval.
|
|
61
|
+
// HACK: Generic params are erased at storage level; factories restore them via typed get+cast on retrieval.
|
|
65
62
|
private readonly gcHandles = new Map<string, GCounterHandle>();
|
|
66
63
|
private readonly pnHandles = new Map<string, PNCounterHandle>();
|
|
67
64
|
private readonly orHandles = new Map<string, ORSetHandle<unknown>>();
|
|
@@ -91,8 +88,25 @@ export class MeridianClient {
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
91
|
+
* Creates and validates a new `MeridianClient` from the supplied configuration.
|
|
92
|
+
*
|
|
93
|
+
* The JWT `token` is parsed and validated synchronously inside the Effect; the
|
|
94
|
+
* Effect fails with `TokenParseError` or `TokenExpiredError` if the token is
|
|
95
|
+
* malformed or expired. The WebSocket connection is opened immediately unless
|
|
96
|
+
* `autoConnect` is set to `false`.
|
|
97
|
+
*
|
|
98
|
+
* @param config - Connection configuration including the server `url`,
|
|
99
|
+
* `namespace`, and a signed `token`.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { Effect } from 'effect';
|
|
104
|
+
* import { MeridianClient } from 'meridian-sdk';
|
|
105
|
+
*
|
|
106
|
+
* const client = await Effect.runPromise(
|
|
107
|
+
* MeridianClient.create({ url: 'ws://localhost:8080', namespace: 'demo', token: myToken })
|
|
108
|
+
* );
|
|
109
|
+
* ```
|
|
96
110
|
*/
|
|
97
111
|
static create(
|
|
98
112
|
config: MeridianClientConfig,
|
|
@@ -102,74 +116,160 @@ export class MeridianClient {
|
|
|
102
116
|
);
|
|
103
117
|
}
|
|
104
118
|
|
|
105
|
-
|
|
106
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Returns a handle for a grow-only counter (GCounter) CRDT.
|
|
121
|
+
*
|
|
122
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
123
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
124
|
+
*
|
|
125
|
+
* @param crdtId - Unique identifier for the CRDT within this namespace.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* const counter = client.gcounter('page-views');
|
|
130
|
+
* counter.increment(5);
|
|
131
|
+
* console.log(counter.value()); // 5
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
107
134
|
gcounter(crdtId: string): GCounterHandle {
|
|
108
|
-
let
|
|
109
|
-
if (!
|
|
110
|
-
|
|
111
|
-
this.gcHandles.set(crdtId,
|
|
135
|
+
let handle = this.gcHandles.get(crdtId);
|
|
136
|
+
if (!handle) {
|
|
137
|
+
handle = new GCounterHandle({ ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport });
|
|
138
|
+
this.gcHandles.set(crdtId, handle);
|
|
112
139
|
this.transport.subscribe(crdtId);
|
|
113
140
|
}
|
|
114
|
-
return
|
|
141
|
+
return handle;
|
|
115
142
|
}
|
|
116
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Returns a handle for a positive-negative counter (PNCounter) CRDT.
|
|
146
|
+
*
|
|
147
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
148
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
149
|
+
*
|
|
150
|
+
* @param crdtId - Unique identifier for the CRDT within this namespace.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* const score = client.pncounter('game-score');
|
|
155
|
+
* score.increment(10);
|
|
156
|
+
* score.decrement(3);
|
|
157
|
+
* console.log(score.value()); // 7
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
117
160
|
pncounter(crdtId: string): PNCounterHandle {
|
|
118
|
-
let
|
|
119
|
-
if (!
|
|
120
|
-
|
|
121
|
-
this.pnHandles.set(crdtId,
|
|
161
|
+
let handle = this.pnHandles.get(crdtId);
|
|
162
|
+
if (!handle) {
|
|
163
|
+
handle = new PNCounterHandle({ ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport });
|
|
164
|
+
this.pnHandles.set(crdtId, handle);
|
|
122
165
|
this.transport.subscribe(crdtId);
|
|
123
166
|
}
|
|
124
|
-
return
|
|
167
|
+
return handle;
|
|
125
168
|
}
|
|
126
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Returns a handle for an Observed-Remove Set (OR-Set) CRDT.
|
|
172
|
+
*
|
|
173
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
174
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
175
|
+
*
|
|
176
|
+
* @param crdtId - Unique identifier for the CRDT within this namespace.
|
|
177
|
+
* @param schema - Optional Effect schema used to decode elements from the wire format.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* ```ts
|
|
181
|
+
* import { Schema } from 'effect';
|
|
182
|
+
*
|
|
183
|
+
* const tags = client.orset('article-tags', Schema.String);
|
|
184
|
+
* tags.add('typescript');
|
|
185
|
+
* tags.remove('typescript');
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
127
188
|
orset<T>(crdtId: string, schema?: Schema.Schema<T>): ORSetHandle<T> {
|
|
128
|
-
let
|
|
129
|
-
if (!
|
|
189
|
+
let handle = this.orHandles.get(crdtId) as ORSetHandle<T> | undefined;
|
|
190
|
+
if (!handle) {
|
|
130
191
|
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
131
|
-
|
|
132
|
-
this.orHandles.set(crdtId,
|
|
192
|
+
handle = schema ? new ORSetHandle<T>({ ...base, schema }) : new ORSetHandle<T>(base);
|
|
193
|
+
this.orHandles.set(crdtId, handle as ORSetHandle<unknown>);
|
|
133
194
|
this.transport.subscribe(crdtId);
|
|
134
195
|
}
|
|
135
|
-
return
|
|
196
|
+
return handle;
|
|
136
197
|
}
|
|
137
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Returns a handle for a Last-Write-Wins register (LWW-Register) CRDT.
|
|
201
|
+
*
|
|
202
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
203
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
204
|
+
*
|
|
205
|
+
* @param crdtId - Unique identifier for the CRDT within this namespace.
|
|
206
|
+
* @param schema - Optional Effect schema used to decode the value from the wire format.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* import { Schema } from 'effect';
|
|
211
|
+
*
|
|
212
|
+
* const theme = client.lwwregister('ui-theme', Schema.Literal('light', 'dark'));
|
|
213
|
+
* theme.set('dark');
|
|
214
|
+
* console.log(theme.value()); // 'dark'
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
138
217
|
lwwregister<T>(crdtId: string, schema?: Schema.Schema<T>): LwwRegisterHandle<T> {
|
|
139
|
-
let
|
|
140
|
-
if (!
|
|
218
|
+
let handle = this.lwHandles.get(crdtId) as LwwRegisterHandle<T> | undefined;
|
|
219
|
+
if (!handle) {
|
|
141
220
|
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
142
|
-
|
|
143
|
-
this.lwHandles.set(crdtId,
|
|
221
|
+
handle = schema ? new LwwRegisterHandle<T>({ ...base, schema }) : new LwwRegisterHandle<T>(base);
|
|
222
|
+
this.lwHandles.set(crdtId, handle as LwwRegisterHandle<unknown>);
|
|
144
223
|
this.transport.subscribe(crdtId);
|
|
145
224
|
}
|
|
146
|
-
return
|
|
225
|
+
return handle;
|
|
147
226
|
}
|
|
148
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Returns a handle for a presence channel CRDT.
|
|
230
|
+
*
|
|
231
|
+
* Handles are cached by `crdtId`; calling this method multiple times with the
|
|
232
|
+
* same id returns the same handle instance and creates only one subscription.
|
|
233
|
+
*
|
|
234
|
+
* @param crdtId - Unique identifier for the presence channel within this namespace.
|
|
235
|
+
* @param schema - Optional Effect schema used to decode peer data from the wire format.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* import { Schema } from 'effect';
|
|
240
|
+
*
|
|
241
|
+
* const room = client.presence('room-1', Schema.Struct({ name: Schema.String }));
|
|
242
|
+
* room.heartbeat({ name: 'Alice' }, 10_000);
|
|
243
|
+
* console.log(room.online()); // [{ clientId: 1, data: { name: 'Alice' }, expiresAtMs: ... }]
|
|
244
|
+
* room.leave();
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
149
247
|
presence<T>(crdtId: string, schema?: Schema.Schema<T>): PresenceHandle<T> {
|
|
150
|
-
let
|
|
151
|
-
if (!
|
|
248
|
+
let handle = this.prHandles.get(crdtId) as PresenceHandle<T> | undefined;
|
|
249
|
+
if (!handle) {
|
|
152
250
|
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
153
|
-
|
|
154
|
-
this.prHandles.set(crdtId,
|
|
251
|
+
handle = schema ? new PresenceHandle<T>({ ...base, schema }) : new PresenceHandle<T>(base);
|
|
252
|
+
this.prHandles.set(crdtId, handle as PresenceHandle<unknown>);
|
|
155
253
|
this.transport.subscribe(crdtId);
|
|
156
254
|
}
|
|
157
|
-
return
|
|
255
|
+
return handle;
|
|
158
256
|
}
|
|
159
257
|
|
|
160
|
-
// ---- Lifecycle ----
|
|
161
|
-
|
|
162
|
-
/** Resolves when the WebSocket is connected and ready. */
|
|
163
258
|
waitForConnected(timeoutMs = 5_000): Promise<void> {
|
|
164
259
|
return this.transport.waitForConnected(timeoutMs);
|
|
165
260
|
}
|
|
166
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Closes the underlying WebSocket connection.
|
|
264
|
+
*
|
|
265
|
+
* Call this when the client is no longer needed to free resources. If you are
|
|
266
|
+
* using `<MeridianProvider>` in React, the provider calls this automatically
|
|
267
|
+
* on unmount.
|
|
268
|
+
*/
|
|
167
269
|
close(): void {
|
|
168
270
|
this.transport.close();
|
|
169
271
|
}
|
|
170
272
|
|
|
171
|
-
// ---- Internal: route ServerMsg.Delta to the right handle ----
|
|
172
|
-
|
|
173
273
|
private handleServerMsg(msg: ServerMsg): void {
|
|
174
274
|
if (!("Delta" in msg)) return;
|
|
175
275
|
const { crdt_id, delta_bytes } = msg.Delta;
|