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.
Files changed (84) hide show
  1. package/biome.json +4 -0
  2. package/dist/auth/token.d.ts +0 -19
  3. package/dist/auth/token.d.ts.map +1 -1
  4. package/dist/auth/token.js +6 -31
  5. package/dist/auth/token.js.map +1 -1
  6. package/dist/client.d.ts +139 -23
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +165 -52
  9. package/dist/client.js.map +1 -1
  10. package/dist/codec.d.ts +7 -35
  11. package/dist/codec.d.ts.map +1 -1
  12. package/dist/codec.js +13 -65
  13. package/dist/codec.js.map +1 -1
  14. package/dist/constants.d.ts +7 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +7 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/crdt/gcounter.d.ts +18 -9
  19. package/dist/crdt/gcounter.d.ts.map +1 -1
  20. package/dist/crdt/gcounter.js +16 -13
  21. package/dist/crdt/gcounter.js.map +1 -1
  22. package/dist/crdt/lwwregister.d.ts +24 -11
  23. package/dist/crdt/lwwregister.d.ts.map +1 -1
  24. package/dist/crdt/lwwregister.js +25 -19
  25. package/dist/crdt/lwwregister.js.map +1 -1
  26. package/dist/crdt/orset.d.ts +25 -13
  27. package/dist/crdt/orset.d.ts.map +1 -1
  28. package/dist/crdt/orset.js +31 -23
  29. package/dist/crdt/orset.js.map +1 -1
  30. package/dist/crdt/pncounter.d.ts +22 -4
  31. package/dist/crdt/pncounter.d.ts.map +1 -1
  32. package/dist/crdt/pncounter.js +28 -14
  33. package/dist/crdt/pncounter.js.map +1 -1
  34. package/dist/crdt/presence.d.ts +33 -13
  35. package/dist/crdt/presence.d.ts.map +1 -1
  36. package/dist/crdt/presence.js +36 -20
  37. package/dist/crdt/presence.js.map +1 -1
  38. package/dist/errors.d.ts +0 -4
  39. package/dist/errors.d.ts.map +1 -1
  40. package/dist/errors.js +0 -16
  41. package/dist/errors.js.map +1 -1
  42. package/dist/schema.d.ts +3 -9
  43. package/dist/schema.d.ts.map +1 -1
  44. package/dist/schema.js +3 -34
  45. package/dist/schema.js.map +1 -1
  46. package/dist/sync/clock.d.ts +1 -20
  47. package/dist/sync/clock.d.ts.map +1 -1
  48. package/dist/sync/clock.js +20 -46
  49. package/dist/sync/clock.js.map +1 -1
  50. package/dist/sync/delta.d.ts +5 -22
  51. package/dist/sync/delta.d.ts.map +1 -1
  52. package/dist/sync/delta.js +18 -26
  53. package/dist/sync/delta.js.map +1 -1
  54. package/dist/transport/http.d.ts +1 -14
  55. package/dist/transport/http.d.ts.map +1 -1
  56. package/dist/transport/http.js +3 -21
  57. package/dist/transport/http.js.map +1 -1
  58. package/dist/transport/websocket.d.ts +0 -27
  59. package/dist/transport/websocket.d.ts.map +1 -1
  60. package/dist/transport/websocket.js +9 -37
  61. package/dist/transport/websocket.js.map +1 -1
  62. package/dist/utils/to-hex.d.ts +2 -0
  63. package/dist/utils/to-hex.d.ts.map +1 -0
  64. package/dist/utils/to-hex.js +2 -0
  65. package/dist/utils/to-hex.js.map +1 -0
  66. package/package.json +6 -3
  67. package/src/auth/token.ts +6 -34
  68. package/src/client.ts +165 -65
  69. package/src/codec.ts +13 -71
  70. package/src/constants.ts +6 -0
  71. package/src/crdt/gcounter.ts +18 -20
  72. package/src/crdt/lwwregister.ts +27 -26
  73. package/src/crdt/orset.ts +32 -29
  74. package/src/crdt/pncounter.ts +30 -21
  75. package/src/crdt/presence.ts +37 -26
  76. package/src/errors.ts +0 -21
  77. package/src/schema.ts +3 -44
  78. package/src/sync/clock.ts +18 -50
  79. package/src/sync/delta.ts +20 -58
  80. package/src/transport/http.ts +3 -33
  81. package/src/transport/websocket.ts +15 -52
  82. package/src/utils/to-hex.ts +1 -0
  83. package/test/integration.test.ts +2 -3
  84. 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 = unpack(bytes);
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 function encodeVectorClock(vc: VectorClock): Uint8Array {
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 function decodeVectorClock(bytes: Uint8Array): Effect.Effect<VectorClock, CodecError> {
42
+ export const decodeVectorClock = (bytes: Uint8Array): Effect.Effect<VectorClock, CodecError> => {
101
43
  let raw: unknown;
102
44
  try {
103
- raw = unpack(bytes);
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
+ };
@@ -0,0 +1,6 @@
1
+ export const BACKOFF_INITIAL_MS = 100;
2
+ export const BACKOFF_MAX_MS = 30_000;
3
+ export const BACKOFF_MULTIPLIER = 2;
4
+ export const JITTER_MULTIPLIER = 0.2;
5
+ export const DEFAULT_TIMEOUT_MS = 5_000;
6
+ export const TOKEN_SKEW_MS = 5_000;
@@ -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
- // ---- Read ----
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
- /** Subscribe to value changes. Returns an unsubscribe function. */
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
- // ---- Write ----
57
-
58
- /** Increment by `amount` (must be > 0). */
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) {
@@ -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, wallMsToBigInt } from "../codec.js";
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
- // ---- Read ----
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
- /** Metadata: when was the last write and by whom. */
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
- // ---- Write ----
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: wireHlc, author: this.clientId } }),
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
- // ---- Read ----
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
- // ---- Write ----
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)!.add(tag);
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(b => b.toString(16).padStart(2, "0")).join("");
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(b => b.toString(16).padStart(2, "0")).join("");
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 l of this.listeners) l(elems);
136
+ for (const listener of this.listeners) listener(elems);
134
137
  }
135
138
  }
@@ -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>; // positive GCounter
12
- n: Record<string, number>; // negative GCounter
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
- // ---- Read ----
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
- // ---- Write ----
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 cur = this.state.p[id] ?? 0;
72
- if (count > cur) { this.state.p[id] = count; changed = true; }
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 cur = this.state.n[id] ?? 0;
76
- if (count > cur) { this.state.n[id] = count; changed = true; }
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 v = this.value();
91
- for (const l of this.listeners) l(v);
99
+ const currentValue = this.value();
100
+ for (const listener of this.listeners) listener(currentValue);
92
101
  }
93
102
  }