meridian-sdk 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +45 -0
  3. package/dist/client.d.ts +26 -0
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +39 -0
  6. package/dist/client.js.map +1 -1
  7. package/dist/crdt/awareness.d.ts +79 -0
  8. package/dist/crdt/awareness.d.ts.map +1 -0
  9. package/dist/crdt/awareness.js +119 -0
  10. package/dist/crdt/awareness.js.map +1 -0
  11. package/dist/crdt/crdtmap.d.ts +4 -4
  12. package/dist/crdt/crdtmap.d.ts.map +1 -1
  13. package/dist/crdt/crdtmap.js +8 -8
  14. package/dist/crdt/crdtmap.js.map +1 -1
  15. package/dist/crdt/gcounter.d.ts +1 -1
  16. package/dist/crdt/gcounter.d.ts.map +1 -1
  17. package/dist/crdt/gcounter.js +2 -2
  18. package/dist/crdt/gcounter.js.map +1 -1
  19. package/dist/crdt/lwwregister.d.ts +1 -1
  20. package/dist/crdt/lwwregister.d.ts.map +1 -1
  21. package/dist/crdt/lwwregister.js +2 -1
  22. package/dist/crdt/lwwregister.js.map +1 -1
  23. package/dist/crdt/orset.d.ts +2 -2
  24. package/dist/crdt/orset.d.ts.map +1 -1
  25. package/dist/crdt/orset.js +4 -2
  26. package/dist/crdt/orset.js.map +1 -1
  27. package/dist/crdt/pncounter.d.ts +2 -2
  28. package/dist/crdt/pncounter.d.ts.map +1 -1
  29. package/dist/crdt/pncounter.js +6 -6
  30. package/dist/crdt/pncounter.js.map +1 -1
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/schema.d.ts +12 -0
  36. package/dist/schema.d.ts.map +1 -1
  37. package/dist/schema.js +16 -1
  38. package/dist/schema.js.map +1 -1
  39. package/dist/transport/websocket.d.ts.map +1 -1
  40. package/dist/transport/websocket.js +9 -1
  41. package/dist/transport/websocket.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client.ts +40 -0
  44. package/src/crdt/awareness.ts +149 -0
  45. package/src/crdt/crdtmap.ts +8 -8
  46. package/src/crdt/gcounter.ts +2 -2
  47. package/src/crdt/lwwregister.ts +2 -1
  48. package/src/crdt/orset.ts +4 -2
  49. package/src/crdt/pncounter.ts +6 -6
  50. package/src/index.ts +2 -0
  51. package/src/schema.ts +18 -1
  52. package/src/transport/websocket.ts +8 -1
  53. package/test/crdt.test.ts +71 -0
@@ -0,0 +1,149 @@
1
+ import { Schema as S } from "effect";
2
+ import type { Schema } from "effect";
3
+ import { encode, decode } from "../codec.js";
4
+ import type { WsTransport } from "../transport/websocket.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // AwarenessEntry
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * A single peer's awareness state on a given channel.
12
+ *
13
+ * Unlike presence entries, awareness entries are ephemeral: they are not
14
+ * persisted and disappear when the sender disconnects or clears their state.
15
+ */
16
+ export interface AwarenessEntry<T> {
17
+ /** The sender's client ID (from their token). */
18
+ clientId: number;
19
+ /** The decoded payload sent by the peer. */
20
+ data: T;
21
+ /** Local timestamp (ms) when this entry was last received. */
22
+ receivedAt: number;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // AwarenessHandle
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Handle for an ephemeral awareness channel.
31
+ *
32
+ * Awareness is a stateless pub/sub channel: updates are fanned out to all
33
+ * other subscribers in the same namespace in real time, but are **not**
34
+ * persisted. If a client connects after a peer's last update, it will not
35
+ * see that peer's state until the peer sends another update.
36
+ *
37
+ * Use this for high-frequency, transient UI state like cursor positions,
38
+ * text selections, or "is typing" indicators — not for durable shared data.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * const cursors = client.awareness<{ x: number; y: number }>('cursors');
43
+ * cursors.update({ x: 100, y: 200 });
44
+ * cursors.onChange(peers => console.log('peers:', peers));
45
+ * ```
46
+ */
47
+ export class AwarenessHandle<T = unknown> {
48
+ private readonly entries = new Map<number, AwarenessEntry<T>>();
49
+ private readonly listeners = new Set<
50
+ (entries: AwarenessEntry<T>[]) => void
51
+ >();
52
+
53
+ constructor(
54
+ private readonly key: string,
55
+ private readonly transport: WsTransport,
56
+ private readonly clientId: number,
57
+ private readonly schema?: Schema.Schema<T>,
58
+ ) {}
59
+
60
+ /**
61
+ * Send our local awareness state to all peers (fire-and-forget).
62
+ *
63
+ * The server does not acknowledge awareness updates — this is a best-effort
64
+ * broadcast. Offline updates are **not** queued: they are dropped silently
65
+ * since awareness data is inherently time-sensitive.
66
+ */
67
+ update(data: T): void {
68
+ const encoded = encode(data);
69
+ this.transport.send({ AwarenessUpdate: { key: this.key, data: encoded } });
70
+ }
71
+
72
+ /**
73
+ * Clear our own entry from remote peers (e.g. on tab hide or component unmount).
74
+ *
75
+ * Sends a `null` payload, which causes all subscribers to remove our entry.
76
+ */
77
+ clear(): void {
78
+ this.transport.send({
79
+ AwarenessUpdate: { key: this.key, data: encode(null) },
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Apply an incoming `AwarenessBroadcast` from the server.
85
+ * Called internally by `MeridianClient.handleServerMsg`.
86
+ *
87
+ * @internal
88
+ */
89
+ applyBroadcast(fromClientId: number, rawData: Uint8Array): void {
90
+ const decoded = decode(rawData);
91
+ if (decoded === null || decoded === undefined) {
92
+ if (this.entries.has(fromClientId)) {
93
+ this.entries.delete(fromClientId);
94
+ this.emit();
95
+ }
96
+ return;
97
+ }
98
+
99
+ const data = this.schema
100
+ ? S.decodeUnknownSync(this.schema)(decoded)
101
+ : (decoded as T);
102
+
103
+ const existing = this.entries.get(fromClientId);
104
+ this.entries.set(fromClientId, {
105
+ clientId: fromClientId,
106
+ data,
107
+ receivedAt: Date.now(),
108
+ });
109
+
110
+ // Only notify if the entry was new or data changed (reference check is
111
+ // sufficient for the "new entry" case; data change always yields a new obj).
112
+ if (!existing || existing.data !== data) {
113
+ this.emit();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Returns the current awareness state for all **other** peers on this channel.
119
+ *
120
+ * Self is excluded (own updates are not echoed back by the server).
121
+ */
122
+ peers(): AwarenessEntry<T>[] {
123
+ const result: AwarenessEntry<T>[] = [];
124
+ for (const entry of this.entries.values()) {
125
+ if (entry.clientId !== this.clientId) result.push(entry);
126
+ }
127
+ return result;
128
+ }
129
+
130
+ /**
131
+ * Subscribe to peer awareness changes.
132
+ *
133
+ * The listener receives the updated `peers()` array whenever a remote peer
134
+ * sends an update or clears their state. Returns an unsubscribe function.
135
+ */
136
+ onChange(
137
+ listener: (entries: AwarenessEntry<T>[]) => void,
138
+ ): () => void {
139
+ this.listeners.add(listener);
140
+ return () => {
141
+ this.listeners.delete(listener);
142
+ };
143
+ }
144
+
145
+ private emit(): void {
146
+ const entries = this.peers();
147
+ for (const fn of this.listeners) fn(entries);
148
+ }
149
+ }
@@ -57,7 +57,7 @@ export class CRDTMapHandle {
57
57
  }
58
58
 
59
59
  /** Increment a GCounter key by `amount` (default `1`). */
60
- incrementCounter(key: string, amount: number = 1): void {
60
+ incrementCounter(key: string, amount: number = 1, ttlMs?: number): void {
61
61
  if (amount <= 0) throw new RangeError("CRDTMap.incrementCounter: amount must be > 0");
62
62
  const op = encode({
63
63
  CRDTMap: {
@@ -66,11 +66,11 @@ export class CRDTMapHandle {
66
66
  op: { GCounter: { client_id: this.clientId, amount } },
67
67
  },
68
68
  });
69
- this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op } });
69
+ this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op, ...(ttlMs !== undefined && { ttl_ms: ttlMs }) } });
70
70
  }
71
71
 
72
72
  /** Increment a PNCounter key by `amount` (default `1`). */
73
- incrementPNCounter(key: string, amount: number = 1): void {
73
+ incrementPNCounter(key: string, amount: number = 1, ttlMs?: number): void {
74
74
  if (amount <= 0) throw new RangeError("CRDTMap.incrementPNCounter: amount must be > 0");
75
75
  const op = encode({
76
76
  CRDTMap: {
@@ -79,11 +79,11 @@ export class CRDTMapHandle {
79
79
  op: { PNCounter: { Increment: { client_id: this.clientId, amount } } },
80
80
  },
81
81
  });
82
- this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op } });
82
+ this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op, ...(ttlMs !== undefined && { ttl_ms: ttlMs }) } });
83
83
  }
84
84
 
85
85
  /** Decrement a PNCounter key by `amount` (default `1`). */
86
- decrementPNCounter(key: string, amount: number = 1): void {
86
+ decrementPNCounter(key: string, amount: number = 1, ttlMs?: number): void {
87
87
  if (amount <= 0) throw new RangeError("CRDTMap.decrementPNCounter: amount must be > 0");
88
88
  const op = encode({
89
89
  CRDTMap: {
@@ -92,7 +92,7 @@ export class CRDTMapHandle {
92
92
  op: { PNCounter: { Decrement: { client_id: this.clientId, amount } } },
93
93
  },
94
94
  });
95
- this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op } });
95
+ this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op, ...(ttlMs !== undefined && { ttl_ms: ttlMs }) } });
96
96
  }
97
97
 
98
98
  /** Add an element to an ORSet key. `tag` must be a 16-byte UUID as Uint8Array. */
@@ -120,7 +120,7 @@ export class CRDTMapHandle {
120
120
  }
121
121
 
122
122
  /** Write a value to an LWW-Register key. */
123
- lwwSet(key: string, value: unknown): void {
123
+ lwwSet(key: string, value: unknown, ttlMs?: number): void {
124
124
  const wallMs = Date.now();
125
125
  const op = encode({
126
126
  CRDTMap: {
@@ -135,7 +135,7 @@ export class CRDTMapHandle {
135
135
  },
136
136
  },
137
137
  });
138
- this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op } });
138
+ this.transport.send({ Op: { crdt_id: this.crdtId, op_bytes: op, ...(ttlMs !== undefined && { ttl_ms: ttlMs }) } });
139
139
  }
140
140
 
141
141
  applyDelta(delta: CRDTMapDelta): void {
@@ -60,7 +60,7 @@ export class GCounterHandle {
60
60
  *
61
61
  * @throws {RangeError} If `amount` is not greater than zero.
62
62
  */
63
- increment(amount: number = 1): void {
63
+ increment(amount: number = 1, ttlMs?: number): void {
64
64
  if (amount <= 0) throw new RangeError("GCounter: increment amount must be > 0");
65
65
 
66
66
  const key = String(this.clientId);
@@ -74,7 +74,7 @@ export class GCounterHandle {
74
74
  },
75
75
  });
76
76
  this.transport.send({
77
- Op: { crdt_id: this.crdtId, op_bytes: op },
77
+ Op: { crdt_id: this.crdtId, op_bytes: op, ...(ttlMs !== undefined && { ttl_ms: ttlMs }) },
78
78
  });
79
79
  }
80
80
 
@@ -65,7 +65,7 @@ export class LwwRegisterHandle<T> {
65
65
  * The write is stamped with the current wall-clock time. If a concurrent
66
66
  * write from another client has a later timestamp it will win over this one.
67
67
  */
68
- set(value: T): void {
68
+ set(value: T, ttlMs?: number): void {
69
69
  const wallMs = Date.now();
70
70
  const hlc = { wall_ms: wallMs, logical: 0, node_id: this.clientId };
71
71
 
@@ -79,6 +79,7 @@ export class LwwRegisterHandle<T> {
79
79
  Op: {
80
80
  crdt_id: this.crdtId,
81
81
  op_bytes: encode({ LwwRegister: { value, hlc: { wall_ms: wallMs, logical: 0, node_id: this.clientId }, author: this.clientId } }),
82
+ ...(ttlMs !== undefined && { ttl_ms: ttlMs }),
82
83
  },
83
84
  });
84
85
  }
package/src/crdt/orset.ts CHANGED
@@ -60,7 +60,7 @@ export class ORSetHandle<T> {
60
60
  * Each call generates a unique tag so concurrent adds of the same value
61
61
  * are treated as distinct entries.
62
62
  */
63
- add(element: T): void {
63
+ add(element: T, ttlMs?: number): void {
64
64
  const tag = crypto.randomUUID();
65
65
  const key = JSON.stringify(element);
66
66
 
@@ -73,6 +73,7 @@ export class ORSetHandle<T> {
73
73
  Op: {
74
74
  crdt_id: this.crdtId,
75
75
  op_bytes: encode({ ORSet: { Add: { element, tag: uuidToBytes(tag) } } }),
76
+ ...(ttlMs !== undefined && { ttl_ms: ttlMs }),
76
77
  },
77
78
  });
78
79
  }
@@ -83,7 +84,7 @@ export class ORSetHandle<T> {
83
84
  * Only the tags observed locally at the time of this call are removed;
84
85
  * concurrently added copies on other clients are left intact.
85
86
  */
86
- remove(element: T): void {
87
+ remove(element: T, ttlMs?: number): void {
87
88
  const key = JSON.stringify(element);
88
89
  const currentTags = Array.from(this.tags.get(key) ?? []);
89
90
  if (currentTags.length === 0) return;
@@ -95,6 +96,7 @@ export class ORSetHandle<T> {
95
96
  Op: {
96
97
  crdt_id: this.crdtId,
97
98
  op_bytes: encode({ ORSet: { Remove: { element, known_tags: currentTags.map(uuidToBytes) } } }),
99
+ ...(ttlMs !== undefined && { ttl_ms: ttlMs }),
98
100
  },
99
101
  });
100
102
  }
@@ -55,12 +55,12 @@ export class PNCounterHandle {
55
55
  *
56
56
  * @throws {RangeError} If `amount` is not greater than zero.
57
57
  */
58
- increment(amount: number = 1): void {
58
+ increment(amount: number = 1, ttlMs?: number): void {
59
59
  if (amount <= 0) throw new RangeError("PNCounter: increment amount must be > 0");
60
60
  const key = String(this.clientId);
61
61
  this.state.p[key] = (this.state.p[key] ?? 0) + amount;
62
62
  this.emit();
63
- this.sendOp({ Increment: { client_id: this.clientId, amount } });
63
+ this.sendOp({ Increment: { client_id: this.clientId, amount } }, ttlMs);
64
64
  }
65
65
 
66
66
  /**
@@ -68,12 +68,12 @@ export class PNCounterHandle {
68
68
  *
69
69
  * @throws {RangeError} If `amount` is not greater than zero.
70
70
  */
71
- decrement(amount: number = 1): void {
71
+ decrement(amount: number = 1, ttlMs?: number): void {
72
72
  if (amount <= 0) throw new RangeError("PNCounter: decrement amount must be > 0");
73
73
  const key = String(this.clientId);
74
74
  this.state.n[key] = (this.state.n[key] ?? 0) + amount;
75
75
  this.emit();
76
- this.sendOp({ Decrement: { client_id: this.clientId, amount } });
76
+ this.sendOp({ Decrement: { client_id: this.clientId, amount } }, ttlMs);
77
77
  }
78
78
 
79
79
  applyDelta(delta: PNCounterDelta): void {
@@ -89,9 +89,9 @@ export class PNCounterHandle {
89
89
  if (changed) this.emit();
90
90
  }
91
91
 
92
- private sendOp(op: unknown): void {
92
+ private sendOp(op: unknown, ttlMs?: number): void {
93
93
  this.transport.send({
94
- Op: { crdt_id: this.crdtId, op_bytes: encode({ PNCounter: op }) },
94
+ Op: { crdt_id: this.crdtId, op_bytes: encode({ PNCounter: op }), ...(ttlMs !== undefined && { ttl_ms: ttlMs }) },
95
95
  });
96
96
  }
97
97
 
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export { PresenceHandle } from "./crdt/presence.js";
23
23
  export type { PresenceEntry } from "./crdt/presence.js";
24
24
  export { CRDTMapHandle } from "./crdt/crdtmap.js";
25
25
  export type { CrdtMapValue } from "./crdt/crdtmap.js";
26
+ export { AwarenessHandle } from "./crdt/awareness.js";
27
+ export type { AwarenessEntry } from "./crdt/awareness.js";
26
28
 
27
29
  // Transport
28
30
  export { HttpClient } from "./transport/http.js";
package/src/schema.ts CHANGED
@@ -43,11 +43,21 @@ export type VectorClock = typeof VectorClock.Type;
43
43
  export const ClientMsg = Schema.Union(
44
44
  Schema.Struct({ Subscribe: Schema.Struct({ crdt_id: Schema.String }) }),
45
45
  Schema.Struct({
46
- Op: Schema.Struct({ crdt_id: Schema.String, op_bytes: Schema.Uint8ArrayFromSelf }),
46
+ Op: Schema.Struct({
47
+ crdt_id: Schema.String,
48
+ op_bytes: Schema.Uint8ArrayFromSelf,
49
+ ttl_ms: Schema.optional(Schema.Number),
50
+ }),
47
51
  }),
48
52
  Schema.Struct({
49
53
  Sync: Schema.Struct({ crdt_id: Schema.String, since_vc: Schema.Uint8ArrayFromSelf }),
50
54
  }),
55
+ Schema.Struct({
56
+ AwarenessUpdate: Schema.Struct({
57
+ key: Schema.String,
58
+ data: Schema.Uint8ArrayFromSelf,
59
+ }),
60
+ }),
51
61
  );
52
62
  export type ClientMsg = typeof ClientMsg.Type;
53
63
 
@@ -62,6 +72,13 @@ export const ServerMsg = Schema.Union(
62
72
  Schema.Struct({
63
73
  Error: Schema.Struct({ code: Schema.Number, message: Schema.String }),
64
74
  }),
75
+ Schema.Struct({
76
+ AwarenessBroadcast: Schema.Struct({
77
+ client_id: ClientId,
78
+ key: Schema.String,
79
+ data: Schema.Uint8ArrayFromSelf,
80
+ }),
81
+ }),
65
82
  );
66
83
  export type ServerMsg = typeof ServerMsg.Type;
67
84
 
@@ -57,6 +57,11 @@ export class WsTransport {
57
57
  }
58
58
 
59
59
  reopen(): void {
60
+ // Disown the current ws before creating a new one so its close event
61
+ // fires with ws !== this.ws and does not trigger scheduleReconnect.
62
+ const old = this.ws;
63
+ this.ws = null;
64
+ old?.close(1000, "reopen");
60
65
  this.closed = false;
61
66
  this.doConnect();
62
67
  }
@@ -209,8 +214,10 @@ export class WsTransport {
209
214
  private flushPendingOps(): void {
210
215
  const ops = this.pendingOps.splice(0);
211
216
  for (let i = 0; i < ops.length; i++) {
217
+ const op = ops[i];
218
+ if (op === undefined) continue;
212
219
  try {
213
- this.ws!.send(encodeClientMsg(ops[i]!));
220
+ this.ws?.send(encodeClientMsg(op));
214
221
  } catch {
215
222
  // WebSocket closed between open and flush — re-queue remaining ops.
216
223
  this.pendingOps.unshift(...ops.slice(i));
package/test/crdt.test.ts CHANGED
@@ -10,6 +10,7 @@ import { PNCounterHandle } from "../src/crdt/pncounter.js";
10
10
  import { ORSetHandle } from "../src/crdt/orset.js";
11
11
  import { LwwRegisterHandle } from "../src/crdt/lwwregister.js";
12
12
  import { PresenceHandle } from "../src/crdt/presence.js";
13
+ import { CRDTMapHandle } from "../src/crdt/crdtmap.js";
13
14
  import type { WsTransport } from "../src/transport/websocket.js";
14
15
 
15
16
  // ---------------------------------------------------------------------------
@@ -290,3 +291,73 @@ describe("PresenceHandle", () => {
290
291
  expect(counts).toEqual([1, 0]);
291
292
  });
292
293
  });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // TTL — ttl_ms forwarded in Op message
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe("ttl_ms forwarding", () => {
300
+ it("GCounter.increment includes ttl_ms when provided", () => {
301
+ const t = stubTransport();
302
+ const h = new GCounterHandle({ ...BASE_OPTS, crdtId: "c", transport: t });
303
+ h.increment(1, 5_000);
304
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(5_000);
305
+ });
306
+
307
+ it("GCounter.increment omits ttl_ms when not provided", () => {
308
+ const t = stubTransport();
309
+ const h = new GCounterHandle({ ...BASE_OPTS, crdtId: "c", transport: t });
310
+ h.increment(1);
311
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBeUndefined();
312
+ });
313
+
314
+ it("PNCounter.increment includes ttl_ms", () => {
315
+ const t = stubTransport();
316
+ const h = new PNCounterHandle({ ...BASE_OPTS, crdtId: "c", transport: t });
317
+ h.increment(1, 10_000);
318
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(10_000);
319
+ });
320
+
321
+ it("PNCounter.decrement includes ttl_ms", () => {
322
+ const t = stubTransport();
323
+ const h = new PNCounterHandle({ ...BASE_OPTS, crdtId: "c", transport: t });
324
+ h.decrement(1, 10_000);
325
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(10_000);
326
+ });
327
+
328
+ it("ORSet.add includes ttl_ms", () => {
329
+ const t = stubTransport();
330
+ const h = new ORSetHandle({ ...BASE_OPTS, crdtId: "s", transport: t });
331
+ h.add("x", 3_000);
332
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(3_000);
333
+ });
334
+
335
+ it("ORSet.remove includes ttl_ms", () => {
336
+ const t = stubTransport();
337
+ const h = new ORSetHandle({ ...BASE_OPTS, crdtId: "s", transport: t });
338
+ h.add("x");
339
+ h.remove("x", 3_000);
340
+ expect((t.sent[1] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(3_000);
341
+ });
342
+
343
+ it("LwwRegister.set includes ttl_ms", () => {
344
+ const t = stubTransport();
345
+ const h = new LwwRegisterHandle({ ...BASE_OPTS, crdtId: "r", transport: t });
346
+ h.set("hello", 60_000);
347
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(60_000);
348
+ });
349
+
350
+ it("CRDTMap.lwwSet includes ttl_ms", () => {
351
+ const t = stubTransport();
352
+ const h = new CRDTMapHandle({ ...BASE_OPTS, crdtId: "m", transport: t });
353
+ h.lwwSet("key", "val", 7_000);
354
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(7_000);
355
+ });
356
+
357
+ it("CRDTMap.incrementCounter includes ttl_ms", () => {
358
+ const t = stubTransport();
359
+ const h = new CRDTMapHandle({ ...BASE_OPTS, crdtId: "m", transport: t });
360
+ h.incrementCounter("views", 1, 7_000);
361
+ expect((t.sent[0] as { Op: { ttl_ms?: number } }).Op.ttl_ms).toBe(7_000);
362
+ });
363
+ });