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.
- package/README.md +119 -0
- package/dist/auth/token.d.ts +27 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/auth/token.js +75 -0
- package/dist/auth/token.js.map +1 -0
- package/dist/client.d.ts +60 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +166 -0
- package/dist/client.js.map +1 -0
- package/dist/codec.d.ts +29 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +72 -0
- package/dist/codec.js.map +1 -0
- package/dist/crdt/gcounter.d.ts +36 -0
- package/dist/crdt/gcounter.d.ts.map +1 -0
- package/dist/crdt/gcounter.js +76 -0
- package/dist/crdt/gcounter.js.map +1 -0
- package/dist/crdt/lwwregister.d.ts +42 -0
- package/dist/crdt/lwwregister.d.ts.map +1 -0
- package/dist/crdt/lwwregister.js +93 -0
- package/dist/crdt/lwwregister.js.map +1 -0
- package/dist/crdt/orset.d.ts +40 -0
- package/dist/crdt/orset.d.ts.map +1 -0
- package/dist/crdt/orset.js +115 -0
- package/dist/crdt/orset.js.map +1 -0
- package/dist/crdt/pncounter.d.ts +32 -0
- package/dist/crdt/pncounter.d.ts.map +1 -0
- package/dist/crdt/pncounter.js +77 -0
- package/dist/crdt/pncounter.js.map +1 -0
- package/dist/crdt/presence.d.ts +45 -0
- package/dist/crdt/presence.d.ts.map +1 -0
- package/dist/crdt/presence.js +133 -0
- package/dist/crdt/presence.js.map +1 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +30 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +118 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +95 -0
- package/dist/schema.js.map +1 -0
- package/dist/sync/clock.d.ts +42 -0
- package/dist/sync/clock.d.ts.map +1 -0
- package/dist/sync/clock.js +95 -0
- package/dist/sync/clock.js.map +1 -0
- package/dist/sync/delta.d.ts +52 -0
- package/dist/sync/delta.d.ts.map +1 -0
- package/dist/sync/delta.js +35 -0
- package/dist/sync/delta.js.map +1 -0
- package/dist/transport/http.d.ts +38 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/transport/http.js +98 -0
- package/dist/transport/http.js.map +1 -0
- package/dist/transport/websocket.d.ts +60 -0
- package/dist/transport/websocket.d.ts.map +1 -0
- package/dist/transport/websocket.js +144 -0
- package/dist/transport/websocket.js.map +1 -0
- package/package.json +31 -0
- package/src/auth/token.ts +96 -0
- package/src/client.ts +197 -0
- package/src/codec.ts +92 -0
- package/src/crdt/gcounter.ts +101 -0
- package/src/crdt/lwwregister.ts +113 -0
- package/src/crdt/orset.ts +131 -0
- package/src/crdt/pncounter.ts +93 -0
- package/src/crdt/presence.ts +162 -0
- package/src/errors.ts +51 -0
- package/src/index.ts +59 -0
- package/src/schema.ts +144 -0
- package/src/sync/clock.ts +117 -0
- package/src/sync/delta.ts +104 -0
- package/src/transport/http.ts +150 -0
- package/src/transport/websocket.ts +189 -0
- package/test/crdt.test.ts +290 -0
- package/test/sync.test.ts +178 -0
- package/test/tsconfig.json +8 -0
- package/tsconfig.json +22 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
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";
|
|
18
|
+
import { WsTransport } from "./transport/websocket.js";
|
|
19
|
+
import { HttpClient } from "./transport/http.js";
|
|
20
|
+
import { GCounterHandle } from "./crdt/gcounter.js";
|
|
21
|
+
import { PNCounterHandle } from "./crdt/pncounter.js";
|
|
22
|
+
import { ORSetHandle } from "./crdt/orset.js";
|
|
23
|
+
import { LwwRegisterHandle } from "./crdt/lwwregister.js";
|
|
24
|
+
import { PresenceHandle } from "./crdt/presence.js";
|
|
25
|
+
import {
|
|
26
|
+
decodeGCounterDelta,
|
|
27
|
+
decodePNCounterDelta,
|
|
28
|
+
decodeORSetDelta,
|
|
29
|
+
decodeLwwDelta,
|
|
30
|
+
decodePresenceDelta,
|
|
31
|
+
} from "./sync/delta.js";
|
|
32
|
+
import { parseAndValidateToken } from "./auth/token.js";
|
|
33
|
+
import type { ServerMsg, TokenClaims } from "./schema.js";
|
|
34
|
+
import type { TokenParseError, TokenExpiredError } from "./errors.js";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Config
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export interface MeridianClientConfig {
|
|
41
|
+
/** Base URL of the Meridian server, e.g. "http://localhost:3000" */
|
|
42
|
+
url: string;
|
|
43
|
+
/** Namespace to connect to. */
|
|
44
|
+
namespace: string;
|
|
45
|
+
/** Meridian token for this namespace. */
|
|
46
|
+
token: string;
|
|
47
|
+
/** If true, open the WebSocket immediately. Default: true */
|
|
48
|
+
autoConnect?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// MeridianClient
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export class MeridianClient {
|
|
56
|
+
readonly namespace: string;
|
|
57
|
+
readonly clientId: number;
|
|
58
|
+
readonly claims: TokenClaims;
|
|
59
|
+
|
|
60
|
+
private readonly transport: WsTransport;
|
|
61
|
+
readonly http: HttpClient;
|
|
62
|
+
|
|
63
|
+
// Handle caches — keyed by crdt_id. Generic params erased at storage level;
|
|
64
|
+
// factories restore them via typed get+cast on retrieval.
|
|
65
|
+
private readonly gcHandles = new Map<string, GCounterHandle>();
|
|
66
|
+
private readonly pnHandles = new Map<string, PNCounterHandle>();
|
|
67
|
+
private readonly orHandles = new Map<string, ORSetHandle<unknown>>();
|
|
68
|
+
private readonly lwHandles = new Map<string, LwwRegisterHandle<unknown>>();
|
|
69
|
+
private readonly prHandles = new Map<string, PresenceHandle<unknown>>();
|
|
70
|
+
|
|
71
|
+
private constructor(config: MeridianClientConfig, claims: TokenClaims) {
|
|
72
|
+
this.namespace = config.namespace;
|
|
73
|
+
this.claims = claims;
|
|
74
|
+
this.clientId = claims.client_id;
|
|
75
|
+
|
|
76
|
+
const httpBase = config.url
|
|
77
|
+
.replace(/^wss:\/\//, "https://")
|
|
78
|
+
.replace(/^ws:\/\//, "http://");
|
|
79
|
+
this.http = new HttpClient({ baseUrl: httpBase, token: config.token });
|
|
80
|
+
|
|
81
|
+
const wsUrl = `${config.url.replace(/^http/, "ws")}/v1/namespaces/${config.namespace}/connect`;
|
|
82
|
+
this.transport = new WsTransport({
|
|
83
|
+
url: wsUrl,
|
|
84
|
+
token: config.token,
|
|
85
|
+
onMessage: (msg) => { this.handleServerMsg(msg); },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (config.autoConnect !== false) {
|
|
89
|
+
this.transport.connect();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a MeridianClient, parsing and validating the token.
|
|
95
|
+
* Returns Effect<MeridianClient, TokenParseError | TokenExpiredError>.
|
|
96
|
+
*/
|
|
97
|
+
static create(
|
|
98
|
+
config: MeridianClientConfig,
|
|
99
|
+
): Effect.Effect<MeridianClient, TokenParseError | TokenExpiredError> {
|
|
100
|
+
return parseAndValidateToken(config.token).pipe(
|
|
101
|
+
Effect.map((claims) => new MeridianClient(config, claims)),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- CRDT factory methods ----
|
|
106
|
+
|
|
107
|
+
gcounter(crdtId: string): GCounterHandle {
|
|
108
|
+
let h = this.gcHandles.get(crdtId);
|
|
109
|
+
if (!h) {
|
|
110
|
+
h = new GCounterHandle({ ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport });
|
|
111
|
+
this.gcHandles.set(crdtId, h);
|
|
112
|
+
this.transport.subscribe(crdtId);
|
|
113
|
+
}
|
|
114
|
+
return h;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pncounter(crdtId: string): PNCounterHandle {
|
|
118
|
+
let h = this.pnHandles.get(crdtId);
|
|
119
|
+
if (!h) {
|
|
120
|
+
h = new PNCounterHandle({ ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport });
|
|
121
|
+
this.pnHandles.set(crdtId, h);
|
|
122
|
+
this.transport.subscribe(crdtId);
|
|
123
|
+
}
|
|
124
|
+
return h;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
orset<T>(crdtId: string, schema?: Schema.Schema<T>): ORSetHandle<T> {
|
|
128
|
+
let h = this.orHandles.get(crdtId) as ORSetHandle<T> | undefined;
|
|
129
|
+
if (!h) {
|
|
130
|
+
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
131
|
+
h = schema ? new ORSetHandle<T>({ ...base, schema }) : new ORSetHandle<T>(base);
|
|
132
|
+
this.orHandles.set(crdtId, h as ORSetHandle<unknown>);
|
|
133
|
+
this.transport.subscribe(crdtId);
|
|
134
|
+
}
|
|
135
|
+
return h;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
lwwregister<T>(crdtId: string, schema?: Schema.Schema<T>): LwwRegisterHandle<T> {
|
|
139
|
+
let h = this.lwHandles.get(crdtId) as LwwRegisterHandle<T> | undefined;
|
|
140
|
+
if (!h) {
|
|
141
|
+
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
142
|
+
h = schema ? new LwwRegisterHandle<T>({ ...base, schema }) : new LwwRegisterHandle<T>(base);
|
|
143
|
+
this.lwHandles.set(crdtId, h as LwwRegisterHandle<unknown>);
|
|
144
|
+
this.transport.subscribe(crdtId);
|
|
145
|
+
}
|
|
146
|
+
return h;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
presence<T>(crdtId: string, schema?: Schema.Schema<T>): PresenceHandle<T> {
|
|
150
|
+
let h = this.prHandles.get(crdtId) as PresenceHandle<T> | undefined;
|
|
151
|
+
if (!h) {
|
|
152
|
+
const base = { ns: this.namespace, crdtId, clientId: this.clientId, transport: this.transport };
|
|
153
|
+
h = schema ? new PresenceHandle<T>({ ...base, schema }) : new PresenceHandle<T>(base);
|
|
154
|
+
this.prHandles.set(crdtId, h as PresenceHandle<unknown>);
|
|
155
|
+
this.transport.subscribe(crdtId);
|
|
156
|
+
}
|
|
157
|
+
return h;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Lifecycle ----
|
|
161
|
+
|
|
162
|
+
close(): void {
|
|
163
|
+
this.transport.close();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- Internal: route ServerMsg.Delta to the right handle ----
|
|
167
|
+
|
|
168
|
+
private handleServerMsg(msg: ServerMsg): void {
|
|
169
|
+
if (!("Delta" in msg)) return;
|
|
170
|
+
const { crdt_id, delta_bytes } = msg.Delta;
|
|
171
|
+
|
|
172
|
+
const gcHandle = this.gcHandles.get(crdt_id);
|
|
173
|
+
if (gcHandle) {
|
|
174
|
+
try { gcHandle.applyDelta(decodeGCounterDelta(delta_bytes)); } catch { /* stale */ }
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const pnHandle = this.pnHandles.get(crdt_id);
|
|
178
|
+
if (pnHandle) {
|
|
179
|
+
try { pnHandle.applyDelta(decodePNCounterDelta(delta_bytes)); } catch { /* stale */ }
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const orHandle = this.orHandles.get(crdt_id);
|
|
183
|
+
if (orHandle) {
|
|
184
|
+
try { orHandle.applyDelta(decodeORSetDelta(delta_bytes)); } catch { /* stale */ }
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const lwHandle = this.lwHandles.get(crdt_id);
|
|
188
|
+
if (lwHandle) {
|
|
189
|
+
try { lwHandle.applyDelta(decodeLwwDelta(delta_bytes)); } catch { /* stale */ }
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const prHandle = this.prHandles.get(crdt_id);
|
|
193
|
+
if (prHandle) {
|
|
194
|
+
try { prHandle.applyDelta(decodePresenceDelta(delta_bytes)); } catch { /* stale */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/codec.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Msgpack codec — wraps msgpackr with Meridian-specific conventions.
|
|
3
|
+
*
|
|
4
|
+
* The server uses rmp-serde with `to_vec_named` (named fields) so enum
|
|
5
|
+
* variants are encoded as `{ "VariantName": { ...fields } }` maps.
|
|
6
|
+
* msgpackr handles this transparently in JS land.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pack, unpack } from "msgpackr";
|
|
10
|
+
import { Effect, Schema } from "effect";
|
|
11
|
+
import { CodecError } from "./errors.js";
|
|
12
|
+
import { ServerMsg } from "./schema.js";
|
|
13
|
+
import type { ClientMsg, VectorClock } from "./schema.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Low-level encode / decode
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Encode any value to msgpack bytes. */
|
|
20
|
+
export function encode(value: unknown): Uint8Array<ArrayBuffer> {
|
|
21
|
+
return pack(value) as Uint8Array<ArrayBuffer>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Decode msgpack bytes to a plain JS value (unsafe — no schema validation). */
|
|
25
|
+
export function decode(bytes: Uint8Array): unknown {
|
|
26
|
+
return unpack(bytes);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Typed helpers for WebSocket frames
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Encode a ClientMsg to a binary WebSocket frame.
|
|
35
|
+
* The msgpack encoding matches rmp-serde's "named" enum format:
|
|
36
|
+
* `{ Subscribe: { crdt_id: "foo" } }` → msgpack map with one key.
|
|
37
|
+
*/
|
|
38
|
+
export function encodeClientMsg(msg: ClientMsg): Uint8Array {
|
|
39
|
+
return encode(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decode a binary WebSocket frame into a ServerMsg.
|
|
44
|
+
* Returns Effect<ServerMsg, CodecError>.
|
|
45
|
+
*/
|
|
46
|
+
export function decodeServerMsg(bytes: Uint8Array): Effect.Effect<ServerMsg, CodecError> {
|
|
47
|
+
let raw: unknown;
|
|
48
|
+
try {
|
|
49
|
+
raw = unpack(bytes);
|
|
50
|
+
} catch {
|
|
51
|
+
return Effect.fail(new CodecError({ message: "msgpack decode failed", raw: bytes }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Schema.decodeUnknown accepts `unknown` input — correct for runtime decode boundaries
|
|
55
|
+
return Schema.decodeUnknown(ServerMsg)(raw).pipe(
|
|
56
|
+
Effect.mapError((parseError) =>
|
|
57
|
+
new CodecError({
|
|
58
|
+
message: `ServerMsg schema validation failed: ${parseError.message}`,
|
|
59
|
+
raw: bytes,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// VectorClock <-> msgpack bytes
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export function encodeVectorClock(vc: VectorClock): Uint8Array {
|
|
70
|
+
// Server expects { entries: { "client_id": version, ... } }
|
|
71
|
+
return encode({ entries: vc });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function decodeVectorClock(bytes: Uint8Array): Effect.Effect<VectorClock, CodecError> {
|
|
75
|
+
let raw: unknown;
|
|
76
|
+
try {
|
|
77
|
+
raw = unpack(bytes);
|
|
78
|
+
} catch {
|
|
79
|
+
return Effect.fail(new CodecError({ message: "VectorClock msgpack decode failed", raw: bytes }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const entries = raw !== null && typeof raw === "object" && "entries" in raw
|
|
83
|
+
? (raw as { entries: unknown }).entries
|
|
84
|
+
: {};
|
|
85
|
+
return Schema.decodeUnknown(Schema.Record({ key: Schema.String, value: Schema.Number }))(
|
|
86
|
+
entries ?? {},
|
|
87
|
+
).pipe(
|
|
88
|
+
Effect.mapError((e) =>
|
|
89
|
+
new CodecError({ message: `VectorClock schema validation failed: ${e.message}`, raw: bytes }),
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GCounter handle — increment-only counter.
|
|
3
|
+
*
|
|
4
|
+
* Local state is kept as a sparse map `{ client_id → count }`.
|
|
5
|
+
* `value()` returns the sum. Deltas are merged on incoming ServerMsg.Delta.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { encode } from "../codec.js";
|
|
9
|
+
import type { WsTransport } from "../transport/websocket.js";
|
|
10
|
+
import type { GCounterDelta } from "../sync/delta.js";
|
|
11
|
+
|
|
12
|
+
export interface GCounterState {
|
|
13
|
+
counts: Record<string, number>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class GCounterHandle {
|
|
17
|
+
private state: GCounterState;
|
|
18
|
+
private readonly clientId: number;
|
|
19
|
+
private readonly crdtId: string;
|
|
20
|
+
private readonly ns: string;
|
|
21
|
+
private readonly transport: WsTransport;
|
|
22
|
+
|
|
23
|
+
/** Emits on every state change. */
|
|
24
|
+
private readonly listeners = new Set<(value: number) => void>();
|
|
25
|
+
|
|
26
|
+
constructor(opts: {
|
|
27
|
+
ns: string;
|
|
28
|
+
crdtId: string;
|
|
29
|
+
clientId: number;
|
|
30
|
+
transport: WsTransport;
|
|
31
|
+
initial?: GCounterState;
|
|
32
|
+
}) {
|
|
33
|
+
this.ns = opts.ns;
|
|
34
|
+
this.crdtId = opts.crdtId;
|
|
35
|
+
this.clientId = opts.clientId;
|
|
36
|
+
this.transport = opts.transport;
|
|
37
|
+
this.state = opts.initial ?? { counts: {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Read ----
|
|
41
|
+
|
|
42
|
+
value(): number {
|
|
43
|
+
return Object.values(this.state.counts).reduce((a, b) => a + b, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
counts(): Readonly<Record<string, number>> {
|
|
47
|
+
return this.state.counts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Subscribe to value changes. Returns an unsubscribe function. */
|
|
51
|
+
onChange(listener: (value: number) => void): () => void {
|
|
52
|
+
this.listeners.add(listener);
|
|
53
|
+
return () => { this.listeners.delete(listener); };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- Write ----
|
|
57
|
+
|
|
58
|
+
/** Increment by `amount` (must be > 0). */
|
|
59
|
+
increment(amount: number = 1): void {
|
|
60
|
+
if (amount <= 0) throw new RangeError("GCounter: increment amount must be > 0");
|
|
61
|
+
|
|
62
|
+
// Optimistic local update
|
|
63
|
+
const key = String(this.clientId);
|
|
64
|
+
this.state.counts[key] = (this.state.counts[key] ?? 0) + amount;
|
|
65
|
+
this.emit();
|
|
66
|
+
|
|
67
|
+
// Send Op to server
|
|
68
|
+
const op = encode({
|
|
69
|
+
GCounter: {
|
|
70
|
+
client_id: this.clientId,
|
|
71
|
+
amount,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
this.transport.send({
|
|
75
|
+
Op: { crdt_id: this.crdtId, op_bytes: op },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Delta application (called by MeridianClient on incoming Delta) ----
|
|
80
|
+
|
|
81
|
+
applyDelta(delta: GCounterDelta): void {
|
|
82
|
+
let changed = false;
|
|
83
|
+
for (const [id, count] of Object.entries(delta.increments)) {
|
|
84
|
+
const current = this.state.counts[id] ?? 0;
|
|
85
|
+
if (count > current) {
|
|
86
|
+
this.state.counts[id] = count;
|
|
87
|
+
changed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (changed) this.emit();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Internal ----
|
|
94
|
+
|
|
95
|
+
private emit(): void {
|
|
96
|
+
const v = this.value();
|
|
97
|
+
for (const listener of this.listeners) {
|
|
98
|
+
listener(v);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LWW Register handle — Last-Write-Wins single-value cell.
|
|
3
|
+
*
|
|
4
|
+
* The winning write is determined by HLC (highest wall_ms wins),
|
|
5
|
+
* tie-broken by author (higher client_id wins). The client sends its
|
|
6
|
+
* local wall clock as HLC wall_ms; the server enforces ±30s drift limit.
|
|
7
|
+
*
|
|
8
|
+
* Pass a `schema` to get runtime validation of incoming deltas:
|
|
9
|
+
* client.lwwregister("id", Schema.Struct({ x: Schema.Number }))
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Schema } from "effect";
|
|
13
|
+
import { encode } from "../codec.js";
|
|
14
|
+
import type { WsTransport } from "../transport/websocket.js";
|
|
15
|
+
import type { LwwDelta, LwwEntry } from "../sync/delta.js";
|
|
16
|
+
|
|
17
|
+
export class LwwRegisterHandle<T> {
|
|
18
|
+
private entry: LwwEntry | null = null;
|
|
19
|
+
private readonly crdtId: string;
|
|
20
|
+
private readonly clientId: number;
|
|
21
|
+
private readonly transport: WsTransport;
|
|
22
|
+
private readonly schema: Schema.Schema<T> | null;
|
|
23
|
+
private readonly listeners = new Set<(value: T | null) => void>();
|
|
24
|
+
|
|
25
|
+
constructor(opts: {
|
|
26
|
+
ns: string;
|
|
27
|
+
crdtId: string;
|
|
28
|
+
clientId: number;
|
|
29
|
+
transport: WsTransport;
|
|
30
|
+
schema?: Schema.Schema<T>;
|
|
31
|
+
initial?: LwwEntry | null;
|
|
32
|
+
}) {
|
|
33
|
+
this.crdtId = opts.crdtId;
|
|
34
|
+
this.clientId = opts.clientId;
|
|
35
|
+
this.transport = opts.transport;
|
|
36
|
+
this.schema = opts.schema ?? null;
|
|
37
|
+
this.entry = opts.initial ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Read ----
|
|
41
|
+
|
|
42
|
+
value(): T | null {
|
|
43
|
+
if (this.entry === null) return null;
|
|
44
|
+
return this.decode(this.entry.value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Metadata: when was the last write and by whom. */
|
|
48
|
+
meta(): { updatedAtMs: number; author: number } | null {
|
|
49
|
+
if (this.entry === null) return null;
|
|
50
|
+
return { updatedAtMs: this.entry.hlc.wall_ms, author: this.entry.author };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onChange(listener: (value: T | null) => void): () => void {
|
|
54
|
+
this.listeners.add(listener);
|
|
55
|
+
return () => { this.listeners.delete(listener); };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- Write ----
|
|
59
|
+
|
|
60
|
+
set(value: T): void {
|
|
61
|
+
const wallMs = Date.now();
|
|
62
|
+
const hlc = { wall_ms: wallMs, logical: 0, node_id: this.clientId };
|
|
63
|
+
|
|
64
|
+
const newEntry: LwwEntry = { value, hlc, author: this.clientId };
|
|
65
|
+
if (this.entryWins(newEntry, this.entry)) {
|
|
66
|
+
this.entry = newEntry;
|
|
67
|
+
this.emit();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.transport.send({
|
|
71
|
+
Op: {
|
|
72
|
+
crdt_id: this.crdtId,
|
|
73
|
+
op_bytes: encode({ LwwRegister: { value, hlc, author: this.clientId } }),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- Delta application ----
|
|
79
|
+
|
|
80
|
+
applyDelta(delta: LwwDelta): void {
|
|
81
|
+
if (delta.entry === null) return;
|
|
82
|
+
if (this.entryWins(delta.entry, this.entry)) {
|
|
83
|
+
this.entry = delta.entry;
|
|
84
|
+
this.emit();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- Internal ----
|
|
89
|
+
|
|
90
|
+
private decode(raw: unknown): T {
|
|
91
|
+
if (this.schema !== null) {
|
|
92
|
+
return Schema.decodeUnknownSync(this.schema)(raw);
|
|
93
|
+
}
|
|
94
|
+
// No schema provided — T defaults to unknown, cast is the caller's responsibility
|
|
95
|
+
return raw as T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private entryWins(candidate: LwwEntry, existing: LwwEntry | null): boolean {
|
|
99
|
+
if (existing === null) return true;
|
|
100
|
+
if (candidate.hlc.wall_ms !== existing.hlc.wall_ms) {
|
|
101
|
+
return candidate.hlc.wall_ms > existing.hlc.wall_ms;
|
|
102
|
+
}
|
|
103
|
+
if (candidate.hlc.logical !== existing.hlc.logical) {
|
|
104
|
+
return candidate.hlc.logical > existing.hlc.logical;
|
|
105
|
+
}
|
|
106
|
+
return candidate.author > existing.author;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private emit(): void {
|
|
110
|
+
const v = this.value();
|
|
111
|
+
for (const l of this.listeners) l(v);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORSet handle — add-wins observed-remove set.
|
|
3
|
+
*
|
|
4
|
+
* Each element has a set of add-tags (UUIDs). Remove only removes tags
|
|
5
|
+
* known at remove time — a concurrent add with a new tag survives.
|
|
6
|
+
*
|
|
7
|
+
* Elements are serialized as JSON for the wire (serde_json::Value).
|
|
8
|
+
*
|
|
9
|
+
* Pass a `schema` to get runtime validation of elements deserialized from deltas:
|
|
10
|
+
* client.orset("id", Schema.Struct({ id: Schema.String }))
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Schema } from "effect";
|
|
14
|
+
import { encode } from "../codec.js";
|
|
15
|
+
import type { WsTransport } from "../transport/websocket.js";
|
|
16
|
+
import type { ORSetDelta } from "../sync/delta.js";
|
|
17
|
+
|
|
18
|
+
export class ORSetHandle<T> {
|
|
19
|
+
/** element (JSON-stringified) → Set of live add-tags */
|
|
20
|
+
private readonly tags = new Map<string, Set<string>>();
|
|
21
|
+
private readonly crdtId: string;
|
|
22
|
+
private readonly clientId: number;
|
|
23
|
+
private readonly transport: WsTransport;
|
|
24
|
+
private readonly schema: Schema.Schema<T> | null;
|
|
25
|
+
private readonly listeners = new Set<(elements: T[]) => void>();
|
|
26
|
+
|
|
27
|
+
constructor(opts: {
|
|
28
|
+
ns: string;
|
|
29
|
+
crdtId: string;
|
|
30
|
+
clientId: number;
|
|
31
|
+
transport: WsTransport;
|
|
32
|
+
schema?: Schema.Schema<T>;
|
|
33
|
+
}) {
|
|
34
|
+
this.crdtId = opts.crdtId;
|
|
35
|
+
this.clientId = opts.clientId;
|
|
36
|
+
this.transport = opts.transport;
|
|
37
|
+
this.schema = opts.schema ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Read ----
|
|
41
|
+
|
|
42
|
+
/** Returns all live elements (add-wins). */
|
|
43
|
+
elements(): T[] {
|
|
44
|
+
return Array.from(this.tags.keys())
|
|
45
|
+
.filter(k => (this.tags.get(k)?.size ?? 0) > 0)
|
|
46
|
+
.map(k => this.decode(JSON.parse(k)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
has(element: T): boolean {
|
|
50
|
+
const key = JSON.stringify(element);
|
|
51
|
+
return (this.tags.get(key)?.size ?? 0) > 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onChange(listener: (elements: T[]) => void): () => void {
|
|
55
|
+
this.listeners.add(listener);
|
|
56
|
+
return () => { this.listeners.delete(listener); };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- Write ----
|
|
60
|
+
|
|
61
|
+
add(element: T): void {
|
|
62
|
+
const tag = crypto.randomUUID();
|
|
63
|
+
const key = JSON.stringify(element);
|
|
64
|
+
|
|
65
|
+
if (!this.tags.has(key)) this.tags.set(key, new Set());
|
|
66
|
+
this.tags.get(key)!.add(tag);
|
|
67
|
+
this.emit();
|
|
68
|
+
|
|
69
|
+
this.transport.send({
|
|
70
|
+
Op: {
|
|
71
|
+
crdt_id: this.crdtId,
|
|
72
|
+
op_bytes: encode({ ORSet: { Add: { element, tag, client_id: this.clientId } } }),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
remove(element: T): void {
|
|
78
|
+
const key = JSON.stringify(element);
|
|
79
|
+
const currentTags = Array.from(this.tags.get(key) ?? []);
|
|
80
|
+
if (currentTags.length === 0) return;
|
|
81
|
+
|
|
82
|
+
this.tags.delete(key);
|
|
83
|
+
this.emit();
|
|
84
|
+
|
|
85
|
+
this.transport.send({
|
|
86
|
+
Op: {
|
|
87
|
+
crdt_id: this.crdtId,
|
|
88
|
+
op_bytes: encode({ ORSet: { Remove: { element, tags: currentTags, client_id: this.clientId } } }),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Delta application ----
|
|
94
|
+
|
|
95
|
+
applyDelta(delta: ORSetDelta): void {
|
|
96
|
+
let changed = false;
|
|
97
|
+
|
|
98
|
+
for (const [elem, addedTags] of Object.entries(delta.added)) {
|
|
99
|
+
if (!this.tags.has(elem)) this.tags.set(elem, new Set());
|
|
100
|
+
const set = this.tags.get(elem)!;
|
|
101
|
+
for (const tag of addedTags) {
|
|
102
|
+
if (!set.has(tag)) { set.add(tag); changed = true; }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const [elem, removedTags] of Object.entries(delta.removed)) {
|
|
107
|
+
const set = this.tags.get(elem);
|
|
108
|
+
if (!set) continue;
|
|
109
|
+
for (const tag of removedTags) {
|
|
110
|
+
if (set.has(tag)) { set.delete(tag); changed = true; }
|
|
111
|
+
}
|
|
112
|
+
if (set.size === 0) this.tags.delete(elem);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (changed) this.emit();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- Internal ----
|
|
119
|
+
|
|
120
|
+
private decode(raw: unknown): T {
|
|
121
|
+
if (this.schema !== null) {
|
|
122
|
+
return Schema.decodeUnknownSync(this.schema)(raw);
|
|
123
|
+
}
|
|
124
|
+
return raw as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private emit(): void {
|
|
128
|
+
const elems = this.elements();
|
|
129
|
+
for (const l of this.listeners) l(elems);
|
|
130
|
+
}
|
|
131
|
+
}
|