meridian-sdk 0.3.3 → 0.3.5
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 +45 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -0
- package/dist/client.js.map +1 -1
- package/dist/crdt/awareness.d.ts +79 -0
- package/dist/crdt/awareness.d.ts.map +1 -0
- package/dist/crdt/awareness.js +119 -0
- package/dist/crdt/awareness.js.map +1 -0
- package/dist/crdt/crdtmap.d.ts +4 -4
- package/dist/crdt/crdtmap.d.ts.map +1 -1
- package/dist/crdt/crdtmap.js +8 -8
- package/dist/crdt/crdtmap.js.map +1 -1
- package/dist/crdt/gcounter.d.ts +1 -1
- package/dist/crdt/gcounter.d.ts.map +1 -1
- package/dist/crdt/gcounter.js +2 -2
- package/dist/crdt/gcounter.js.map +1 -1
- package/dist/crdt/lwwregister.d.ts +1 -1
- package/dist/crdt/lwwregister.d.ts.map +1 -1
- package/dist/crdt/lwwregister.js +2 -1
- package/dist/crdt/lwwregister.js.map +1 -1
- package/dist/crdt/orset.d.ts +2 -2
- package/dist/crdt/orset.d.ts.map +1 -1
- package/dist/crdt/orset.js +4 -2
- package/dist/crdt/orset.js.map +1 -1
- package/dist/crdt/pncounter.d.ts +2 -2
- package/dist/crdt/pncounter.d.ts.map +1 -1
- package/dist/crdt/pncounter.js +6 -6
- package/dist/crdt/pncounter.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +12 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +16 -1
- package/dist/schema.js.map +1 -1
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +9 -1
- package/dist/transport/websocket.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +40 -0
- package/src/crdt/awareness.ts +149 -0
- package/src/crdt/crdtmap.ts +8 -8
- package/src/crdt/gcounter.ts +2 -2
- package/src/crdt/lwwregister.ts +2 -1
- package/src/crdt/orset.ts +4 -2
- package/src/crdt/pncounter.ts +6 -6
- package/src/index.ts +2 -0
- package/src/schema.ts +18 -1
- package/src/transport/websocket.ts +8 -1
- 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
|
+
}
|
package/src/crdt/crdtmap.ts
CHANGED
|
@@ -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 {
|
package/src/crdt/gcounter.ts
CHANGED
|
@@ -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
|
|
package/src/crdt/lwwregister.ts
CHANGED
|
@@ -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
|
}
|
package/src/crdt/pncounter.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
+
});
|