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
package/src/codec.ts
CHANGED
|
@@ -1,57 +1,23 @@
|
|
|
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";
|
|
1
|
+
import { decode as msgpackDecode, encode as msgpackEncode } from "@msgpack/msgpack";
|
|
10
2
|
import { Effect, Schema } from "effect";
|
|
11
3
|
import { CodecError } from "./errors.js";
|
|
12
4
|
import { ServerMsg } from "./schema.js";
|
|
13
5
|
import type { ClientMsg, VectorClock } from "./schema.js";
|
|
14
6
|
|
|
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
|
-
}
|
|
7
|
+
export const encode = (value: unknown): Uint8Array => msgpackEncode(value);
|
|
28
8
|
|
|
29
|
-
|
|
30
|
-
// Typed helpers for WebSocket frames
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
9
|
+
export const decode = (bytes: Uint8Array): unknown => msgpackDecode(bytes);
|
|
32
10
|
|
|
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
|
-
}
|
|
11
|
+
export const encodeClientMsg = (msg: ClientMsg): Uint8Array => encode(msg);
|
|
41
12
|
|
|
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> {
|
|
13
|
+
export const decodeServerMsg = (bytes: Uint8Array): Effect.Effect<ServerMsg, CodecError> => {
|
|
47
14
|
let raw: unknown;
|
|
48
15
|
try {
|
|
49
|
-
raw =
|
|
16
|
+
raw = decode(bytes);
|
|
50
17
|
} catch {
|
|
51
18
|
return Effect.fail(new CodecError({ message: "msgpack decode failed", raw: bytes }));
|
|
52
19
|
}
|
|
53
20
|
|
|
54
|
-
// Schema.decodeUnknown accepts `unknown` input — correct for runtime decode boundaries
|
|
55
21
|
return Schema.decodeUnknown(ServerMsg)(raw).pipe(
|
|
56
22
|
Effect.mapError((parseError) =>
|
|
57
23
|
new CodecError({
|
|
@@ -60,47 +26,23 @@ export function decodeServerMsg(bytes: Uint8Array): Effect.Effect<ServerMsg, Cod
|
|
|
60
26
|
}),
|
|
61
27
|
),
|
|
62
28
|
);
|
|
63
|
-
}
|
|
29
|
+
};
|
|
64
30
|
|
|
65
|
-
|
|
66
|
-
// Wire type helpers
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Convert a UUID string ("xxxxxxxx-xxxx-...") to a 16-byte Uint8Array.
|
|
71
|
-
* Rust's `uuid::Uuid` is serialized by rmp-serde as raw bytes (bin type).
|
|
72
|
-
*/
|
|
73
|
-
export function uuidToBytes(uuid: string): Uint8Array {
|
|
31
|
+
export const uuidToBytes = (uuid: string): Uint8Array => {
|
|
74
32
|
const hex = uuid.replace(/-/g, "");
|
|
75
33
|
const bytes = new Uint8Array(16);
|
|
76
34
|
for (let i = 0; i < 16; i++) {
|
|
77
35
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
78
36
|
}
|
|
79
37
|
return bytes;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Encode a wall-clock timestamp (ms) as BigInt for msgpack u64 encoding.
|
|
84
|
-
* msgpackr encodes JS `number` as float64 for large values; Rust u64 fields
|
|
85
|
-
* require an integer encoding — BigInt forces msgpackr to use uint64.
|
|
86
|
-
*/
|
|
87
|
-
export function wallMsToBigInt(ms: number): bigint {
|
|
88
|
-
return BigInt(ms);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// VectorClock <-> msgpack bytes
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
38
|
+
};
|
|
94
39
|
|
|
95
|
-
export
|
|
96
|
-
// Server expects { entries: { "client_id": version, ... } }
|
|
97
|
-
return encode({ entries: vc });
|
|
98
|
-
}
|
|
40
|
+
export const encodeVectorClock = (vc: VectorClock): Uint8Array => encode({ entries: vc });
|
|
99
41
|
|
|
100
|
-
export
|
|
42
|
+
export const decodeVectorClock = (bytes: Uint8Array): Effect.Effect<VectorClock, CodecError> => {
|
|
101
43
|
let raw: unknown;
|
|
102
44
|
try {
|
|
103
|
-
raw =
|
|
45
|
+
raw = decode(bytes);
|
|
104
46
|
} catch {
|
|
105
47
|
return Effect.fail(new CodecError({ message: "VectorClock msgpack decode failed", raw: bytes }));
|
|
106
48
|
}
|
|
@@ -115,4 +57,4 @@ export function decodeVectorClock(bytes: Uint8Array): Effect.Effect<VectorClock,
|
|
|
115
57
|
new CodecError({ message: `VectorClock schema validation failed: ${e.message}`, raw: bytes }),
|
|
116
58
|
),
|
|
117
59
|
);
|
|
118
|
-
}
|
|
60
|
+
};
|
package/src/constants.ts
ADDED
package/src/crdt/gcounter.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
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
1
|
import { encode } from "../codec.js";
|
|
9
2
|
import type { WsTransport } from "../transport/websocket.js";
|
|
10
3
|
import type { GCounterDelta } from "../sync/delta.js";
|
|
@@ -13,6 +6,12 @@ export interface GCounterState {
|
|
|
13
6
|
counts: Record<string, number>;
|
|
14
7
|
}
|
|
15
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Low-level handle for a grow-only counter (GCounter) CRDT.
|
|
11
|
+
*
|
|
12
|
+
* Obtained via `MeridianClient.gcounter()`. Prefer the `useGCounter` React hook
|
|
13
|
+
* for component-level usage; use this handle directly in non-React environments.
|
|
14
|
+
*/
|
|
16
15
|
export class GCounterHandle {
|
|
17
16
|
private state: GCounterState;
|
|
18
17
|
private readonly clientId: number;
|
|
@@ -20,7 +19,6 @@ export class GCounterHandle {
|
|
|
20
19
|
private readonly ns: string;
|
|
21
20
|
private readonly transport: WsTransport;
|
|
22
21
|
|
|
23
|
-
/** Emits on every state change. */
|
|
24
22
|
private readonly listeners = new Set<(value: number) => void>();
|
|
25
23
|
|
|
26
24
|
constructor(opts: {
|
|
@@ -37,34 +35,38 @@ export class GCounterHandle {
|
|
|
37
35
|
this.state = opts.initial ?? { counts: {} };
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
/** Returns the current counter value (sum of all client contributions). */
|
|
42
39
|
value(): number {
|
|
43
40
|
return Object.values(this.state.counts).reduce((a, b) => a + b, 0);
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
/** Returns the raw per-client contribution map, keyed by client id string. */
|
|
46
44
|
counts(): Readonly<Record<string, number>> {
|
|
47
45
|
return this.state.counts;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Registers a listener that is called whenever the counter value changes.
|
|
50
|
+
*
|
|
51
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
52
|
+
*/
|
|
51
53
|
onChange(listener: (value: number) => void): () => void {
|
|
52
54
|
this.listeners.add(listener);
|
|
53
55
|
return () => { this.listeners.delete(listener); };
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Increments the counter by `amount` (default `1`) and broadcasts the delta.
|
|
60
|
+
*
|
|
61
|
+
* @throws {RangeError} If `amount` is not greater than zero.
|
|
62
|
+
*/
|
|
59
63
|
increment(amount: number = 1): void {
|
|
60
64
|
if (amount <= 0) throw new RangeError("GCounter: increment amount must be > 0");
|
|
61
65
|
|
|
62
|
-
// Optimistic local update
|
|
63
66
|
const key = String(this.clientId);
|
|
64
67
|
this.state.counts[key] = (this.state.counts[key] ?? 0) + amount;
|
|
65
68
|
this.emit();
|
|
66
69
|
|
|
67
|
-
// Send Op to server
|
|
68
70
|
const op = encode({
|
|
69
71
|
GCounter: {
|
|
70
72
|
client_id: this.clientId,
|
|
@@ -76,8 +78,6 @@ export class GCounterHandle {
|
|
|
76
78
|
});
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
// ---- Delta application (called by MeridianClient on incoming Delta) ----
|
|
80
|
-
|
|
81
81
|
applyDelta(delta: GCounterDelta): void {
|
|
82
82
|
let changed = false;
|
|
83
83
|
for (const [id, count] of Object.entries(delta.counters)) {
|
|
@@ -90,8 +90,6 @@ export class GCounterHandle {
|
|
|
90
90
|
if (changed) this.emit();
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// ---- Internal ----
|
|
94
|
-
|
|
95
93
|
private emit(): void {
|
|
96
94
|
const v = this.value();
|
|
97
95
|
for (const listener of this.listeners) {
|
package/src/crdt/lwwregister.ts
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
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
1
|
import { Schema } from "effect";
|
|
13
|
-
import { encode
|
|
2
|
+
import { encode } from "../codec.js";
|
|
14
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
15
4
|
import type { LwwDelta, LwwEntry } from "../sync/delta.js";
|
|
16
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Low-level handle for a Last-Write-Wins register (LWW-Register) CRDT.
|
|
8
|
+
*
|
|
9
|
+
* Obtained via `MeridianClient.lwwregister()`. Prefer the `useLwwRegister` React
|
|
10
|
+
* hook for component-level usage; use this handle directly in non-React environments.
|
|
11
|
+
*/
|
|
17
12
|
export class LwwRegisterHandle<T> {
|
|
18
13
|
private entry: LwwEntry | null = null;
|
|
19
14
|
private readonly crdtId: string;
|
|
@@ -37,26 +32,39 @@ export class LwwRegisterHandle<T> {
|
|
|
37
32
|
this.entry = opts.initial ?? null;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
35
|
+
/** Returns the current register value, or `null` if no value has been written yet. */
|
|
42
36
|
value(): T | null {
|
|
43
37
|
if (this.entry === null) return null;
|
|
44
38
|
return this.decode(this.entry.value);
|
|
45
39
|
}
|
|
46
40
|
|
|
47
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Returns metadata about the winning entry, or `null` if the register is empty.
|
|
43
|
+
*
|
|
44
|
+
* `updatedAtMs` is the wall-clock timestamp of the write; `author` is the
|
|
45
|
+
* numeric client id of the writer.
|
|
46
|
+
*/
|
|
48
47
|
meta(): { updatedAtMs: number; author: number } | null {
|
|
49
48
|
if (this.entry === null) return null;
|
|
50
49
|
return { updatedAtMs: Number(this.entry.hlc.wall_ms), author: Number(this.entry.author) };
|
|
51
50
|
}
|
|
52
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Registers a listener that is called whenever the register value changes.
|
|
54
|
+
*
|
|
55
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
56
|
+
*/
|
|
53
57
|
onChange(listener: (value: T | null) => void): () => void {
|
|
54
58
|
this.listeners.add(listener);
|
|
55
59
|
return () => { this.listeners.delete(listener); };
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Writes `value` to the register and broadcasts the operation.
|
|
64
|
+
*
|
|
65
|
+
* The write is stamped with the current wall-clock time. If a concurrent
|
|
66
|
+
* write from another client has a later timestamp it will win over this one.
|
|
67
|
+
*/
|
|
60
68
|
set(value: T): void {
|
|
61
69
|
const wallMs = Date.now();
|
|
62
70
|
const hlc = { wall_ms: wallMs, logical: 0, node_id: this.clientId };
|
|
@@ -67,19 +75,14 @@ export class LwwRegisterHandle<T> {
|
|
|
67
75
|
this.emit();
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
// wall_ms must be BigInt — msgpackr encodes large JS numbers as float64,
|
|
71
|
-
// but Rust expects u64 integer encoding
|
|
72
|
-
const wireHlc = { wall_ms: wallMsToBigInt(wallMs), logical: 0, node_id: this.clientId };
|
|
73
78
|
this.transport.send({
|
|
74
79
|
Op: {
|
|
75
80
|
crdt_id: this.crdtId,
|
|
76
|
-
op_bytes: encode({ LwwRegister: { value, hlc:
|
|
81
|
+
op_bytes: encode({ LwwRegister: { value, hlc: { wall_ms: wallMs, logical: 0, node_id: this.clientId }, author: this.clientId } }),
|
|
77
82
|
},
|
|
78
83
|
});
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
// ---- Delta application ----
|
|
82
|
-
|
|
83
86
|
applyDelta(delta: LwwDelta): void {
|
|
84
87
|
if (delta.entry === null) return;
|
|
85
88
|
if (this.entryWins(delta.entry, this.entry)) {
|
|
@@ -88,13 +91,11 @@ export class LwwRegisterHandle<T> {
|
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
// ---- Internal ----
|
|
92
|
-
|
|
93
94
|
private decode(raw: unknown): T {
|
|
94
95
|
if (this.schema !== null) {
|
|
95
96
|
return Schema.decodeUnknownSync(this.schema)(raw);
|
|
96
97
|
}
|
|
97
|
-
// No schema provided — T defaults to unknown, cast is the caller's responsibility
|
|
98
|
+
// HACK: No schema provided — T defaults to unknown, cast is the caller's responsibility.
|
|
98
99
|
return raw as T;
|
|
99
100
|
}
|
|
100
101
|
|
package/src/crdt/orset.ts
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
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
1
|
import { Schema } from "effect";
|
|
14
2
|
import { encode, uuidToBytes } from "../codec.js";
|
|
15
3
|
import type { WsTransport } from "../transport/websocket.js";
|
|
16
4
|
import type { ORSetDelta } from "../sync/delta.js";
|
|
5
|
+
import { toHex } from "../utils/to-hex.js";
|
|
17
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Low-level handle for an Observed-Remove Set (OR-Set) CRDT.
|
|
9
|
+
*
|
|
10
|
+
* Obtained via `MeridianClient.orset()`. Prefer the `useORSet` React hook for
|
|
11
|
+
* component-level usage; use this handle directly in non-React environments.
|
|
12
|
+
*/
|
|
18
13
|
export class ORSetHandle<T> {
|
|
19
|
-
/** element (JSON-stringified) → Set of live add-tags */
|
|
20
14
|
private readonly tags = new Map<string, Set<string>>();
|
|
21
15
|
private readonly crdtId: string;
|
|
22
16
|
private readonly clientId: number;
|
|
@@ -37,36 +31,44 @@ export class ORSetHandle<T> {
|
|
|
37
31
|
this.schema = opts.schema ?? null;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** Returns all live elements (add-wins). */
|
|
34
|
+
/** Returns the current set elements as an array, decoded via the optional schema. */
|
|
43
35
|
elements(): T[] {
|
|
44
36
|
return Array.from(this.tags.keys())
|
|
45
37
|
.filter(k => (this.tags.get(k)?.size ?? 0) > 0)
|
|
46
38
|
.map(k => this.decode(JSON.parse(k)));
|
|
47
39
|
}
|
|
48
40
|
|
|
41
|
+
/** Returns `true` if the set currently contains `element`. */
|
|
49
42
|
has(element: T): boolean {
|
|
50
43
|
const key = JSON.stringify(element);
|
|
51
44
|
return (this.tags.get(key)?.size ?? 0) > 0;
|
|
52
45
|
}
|
|
53
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Registers a listener that is called whenever the set contents change.
|
|
49
|
+
*
|
|
50
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
51
|
+
*/
|
|
54
52
|
onChange(listener: (elements: T[]) => void): () => void {
|
|
55
53
|
this.listeners.add(listener);
|
|
56
54
|
return () => { this.listeners.delete(listener); };
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Adds `element` to the set and broadcasts the operation.
|
|
59
|
+
*
|
|
60
|
+
* Each call generates a unique tag so concurrent adds of the same value
|
|
61
|
+
* are treated as distinct entries.
|
|
62
|
+
*/
|
|
61
63
|
add(element: T): void {
|
|
62
64
|
const tag = crypto.randomUUID();
|
|
63
65
|
const key = JSON.stringify(element);
|
|
64
66
|
|
|
65
67
|
if (!this.tags.has(key)) this.tags.set(key, new Set());
|
|
66
|
-
this.tags.get(key)
|
|
68
|
+
const tagSet = this.tags.get(key) as Set<string>;
|
|
69
|
+
tagSet.add(tag);
|
|
67
70
|
this.emit();
|
|
68
71
|
|
|
69
|
-
// Rust Uuid is serialized as 16-byte bin — encode tag as bytes
|
|
70
72
|
this.transport.send({
|
|
71
73
|
Op: {
|
|
72
74
|
crdt_id: this.crdtId,
|
|
@@ -75,6 +77,12 @@ export class ORSetHandle<T> {
|
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Removes `element` from the set and broadcasts the operation.
|
|
82
|
+
*
|
|
83
|
+
* Only the tags observed locally at the time of this call are removed;
|
|
84
|
+
* concurrently added copies on other clients are left intact.
|
|
85
|
+
*/
|
|
78
86
|
remove(element: T): void {
|
|
79
87
|
const key = JSON.stringify(element);
|
|
80
88
|
const currentTags = Array.from(this.tags.get(key) ?? []);
|
|
@@ -83,7 +91,6 @@ export class ORSetHandle<T> {
|
|
|
83
91
|
this.tags.delete(key);
|
|
84
92
|
this.emit();
|
|
85
93
|
|
|
86
|
-
// Rust expects known_tags as a set of 16-byte UUIDs
|
|
87
94
|
this.transport.send({
|
|
88
95
|
Op: {
|
|
89
96
|
crdt_id: this.crdtId,
|
|
@@ -92,16 +99,14 @@ export class ORSetHandle<T> {
|
|
|
92
99
|
});
|
|
93
100
|
}
|
|
94
101
|
|
|
95
|
-
// ---- Delta application ----
|
|
96
|
-
|
|
97
102
|
applyDelta(delta: ORSetDelta): void {
|
|
98
103
|
let changed = false;
|
|
99
104
|
|
|
100
105
|
for (const [elem, addedTags] of Object.entries(delta.adds)) {
|
|
101
106
|
if (!this.tags.has(elem)) this.tags.set(elem, new Set());
|
|
102
|
-
const set = this.tags.get(elem)
|
|
107
|
+
const set = this.tags.get(elem) as Set<string>;
|
|
103
108
|
for (const tag of addedTags) {
|
|
104
|
-
const tagStr = Array.from(tag).map(
|
|
109
|
+
const tagStr = Array.from(tag).map(toHex).join("");
|
|
105
110
|
if (!set.has(tagStr)) { set.add(tagStr); changed = true; }
|
|
106
111
|
}
|
|
107
112
|
}
|
|
@@ -110,7 +115,7 @@ export class ORSetHandle<T> {
|
|
|
110
115
|
const set = this.tags.get(elem);
|
|
111
116
|
if (!set) continue;
|
|
112
117
|
for (const tag of removedTags) {
|
|
113
|
-
const tagStr = Array.from(tag).map(
|
|
118
|
+
const tagStr = Array.from(tag).map(toHex).join("");
|
|
114
119
|
if (set.has(tagStr)) { set.delete(tagStr); changed = true; }
|
|
115
120
|
}
|
|
116
121
|
if (set.size === 0) this.tags.delete(elem);
|
|
@@ -119,8 +124,6 @@ export class ORSetHandle<T> {
|
|
|
119
124
|
if (changed) this.emit();
|
|
120
125
|
}
|
|
121
126
|
|
|
122
|
-
// ---- Internal ----
|
|
123
|
-
|
|
124
127
|
private decode(raw: unknown): T {
|
|
125
128
|
if (this.schema !== null) {
|
|
126
129
|
return Schema.decodeUnknownSync(this.schema)(raw);
|
|
@@ -130,6 +133,6 @@ export class ORSetHandle<T> {
|
|
|
130
133
|
|
|
131
134
|
private emit(): void {
|
|
132
135
|
const elems = this.elements();
|
|
133
|
-
for (const
|
|
136
|
+
for (const listener of this.listeners) listener(elems);
|
|
134
137
|
}
|
|
135
138
|
}
|
package/src/crdt/pncounter.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PNCounter handle — increment and decrement counter.
|
|
3
|
-
* value = sum(increments) - sum(decrements). Can be negative.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { encode } from "../codec.js";
|
|
7
2
|
import type { WsTransport } from "../transport/websocket.js";
|
|
8
3
|
import type { PNCounterDelta } from "../sync/delta.js";
|
|
9
4
|
|
|
10
5
|
export interface PNCounterState {
|
|
11
|
-
p: Record<string, number>;
|
|
12
|
-
n: Record<string, number>;
|
|
6
|
+
p: Record<string, number>;
|
|
7
|
+
n: Record<string, number>;
|
|
13
8
|
}
|
|
14
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Low-level handle for a positive-negative counter (PNCounter) CRDT.
|
|
12
|
+
*
|
|
13
|
+
* Obtained via `MeridianClient.pncounter()`. Prefer the `usePNCounter` React hook
|
|
14
|
+
* for component-level usage; use this handle directly in non-React environments.
|
|
15
|
+
*/
|
|
15
16
|
export class PNCounterHandle {
|
|
16
17
|
private state: PNCounterState;
|
|
17
18
|
private readonly clientId: number;
|
|
@@ -32,21 +33,28 @@ export class PNCounterHandle {
|
|
|
32
33
|
this.state = opts.initial ?? { p: {}, n: {} };
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
/** Returns the current net counter value (sum of increments minus sum of decrements). */
|
|
37
37
|
value(): number {
|
|
38
38
|
const pos = Object.values(this.state.p).reduce((a, b) => a + b, 0);
|
|
39
39
|
const neg = Object.values(this.state.n).reduce((a, b) => a + b, 0);
|
|
40
40
|
return pos - neg;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Registers a listener that is called whenever the counter value changes.
|
|
45
|
+
*
|
|
46
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
47
|
+
*/
|
|
43
48
|
onChange(listener: (value: number) => void): () => void {
|
|
44
49
|
this.listeners.add(listener);
|
|
45
50
|
return () => { this.listeners.delete(listener); };
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Increments the counter by `amount` (default `1`) and broadcasts the delta.
|
|
55
|
+
*
|
|
56
|
+
* @throws {RangeError} If `amount` is not greater than zero.
|
|
57
|
+
*/
|
|
50
58
|
increment(amount: number = 1): void {
|
|
51
59
|
if (amount <= 0) throw new RangeError("PNCounter: increment amount must be > 0");
|
|
52
60
|
const key = String(this.clientId);
|
|
@@ -55,6 +63,11 @@ export class PNCounterHandle {
|
|
|
55
63
|
this.sendOp({ Increment: { client_id: this.clientId, amount } });
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Decrements the counter by `amount` (default `1`) and broadcasts the delta.
|
|
68
|
+
*
|
|
69
|
+
* @throws {RangeError} If `amount` is not greater than zero.
|
|
70
|
+
*/
|
|
58
71
|
decrement(amount: number = 1): void {
|
|
59
72
|
if (amount <= 0) throw new RangeError("PNCounter: decrement amount must be > 0");
|
|
60
73
|
const key = String(this.clientId);
|
|
@@ -63,23 +76,19 @@ export class PNCounterHandle {
|
|
|
63
76
|
this.sendOp({ Decrement: { client_id: this.clientId, amount } });
|
|
64
77
|
}
|
|
65
78
|
|
|
66
|
-
// ---- Delta application ----
|
|
67
|
-
|
|
68
79
|
applyDelta(delta: PNCounterDelta): void {
|
|
69
80
|
let changed = false;
|
|
70
81
|
for (const [id, count] of Object.entries(delta.pos?.counters ?? {})) {
|
|
71
|
-
const
|
|
72
|
-
if (count >
|
|
82
|
+
const currentCount = this.state.p[id] ?? 0;
|
|
83
|
+
if (count > currentCount) { this.state.p[id] = count; changed = true; }
|
|
73
84
|
}
|
|
74
85
|
for (const [id, count] of Object.entries(delta.neg?.counters ?? {})) {
|
|
75
|
-
const
|
|
76
|
-
if (count >
|
|
86
|
+
const currentCount = this.state.n[id] ?? 0;
|
|
87
|
+
if (count > currentCount) { this.state.n[id] = count; changed = true; }
|
|
77
88
|
}
|
|
78
89
|
if (changed) this.emit();
|
|
79
90
|
}
|
|
80
91
|
|
|
81
|
-
// ---- Internal ----
|
|
82
|
-
|
|
83
92
|
private sendOp(op: unknown): void {
|
|
84
93
|
this.transport.send({
|
|
85
94
|
Op: { crdt_id: this.crdtId, op_bytes: encode({ PNCounter: op }) },
|
|
@@ -87,7 +96,7 @@ export class PNCounterHandle {
|
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
private emit(): void {
|
|
90
|
-
const
|
|
91
|
-
for (const
|
|
99
|
+
const currentValue = this.value();
|
|
100
|
+
for (const listener of this.listeners) listener(currentValue);
|
|
92
101
|
}
|
|
93
102
|
}
|