meridian-sdk 0.2.0 → 0.2.2
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/tsconfig.json +2 -15
package/src/crdt/presence.ts
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Presence handle — TTL-aware presence tracking.
|
|
3
|
-
*
|
|
4
|
-
* `heartbeat(data, ttlMs)` upserts the local client's entry.
|
|
5
|
-
* `leave()` creates an explicit tombstone (ttl = 0).
|
|
6
|
-
* Background GC on the server prunes expired entries every 5s.
|
|
7
|
-
*
|
|
8
|
-
* Pass a `schema` to get runtime validation of incoming delta data:
|
|
9
|
-
* client.presence("id", Schema.Struct({ cursor: Schema.Tuple(Schema.Number, 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 { PresenceDelta, PresenceEntryDelta } from "../sync/delta.js";
|
|
16
5
|
|
|
@@ -20,6 +9,13 @@ export interface PresenceEntry<T> {
|
|
|
20
9
|
expiresAtMs: number;
|
|
21
10
|
}
|
|
22
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Low-level handle for a presence channel CRDT.
|
|
14
|
+
*
|
|
15
|
+
* Obtained via `MeridianClient.presence()`. Prefer the `usePresence` React hook
|
|
16
|
+
* for component-level usage (it manages heartbeat timers and cleanup automatically);
|
|
17
|
+
* use this handle directly in non-React environments.
|
|
18
|
+
*/
|
|
23
19
|
export class PresenceHandle<T> {
|
|
24
20
|
private readonly entries = new Map<string, PresenceEntry<T>>();
|
|
25
21
|
private readonly crdtId: string;
|
|
@@ -41,9 +37,10 @@ export class PresenceHandle<T> {
|
|
|
41
37
|
this.schema = opts.schema ?? null;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Returns all currently online entries — those whose TTL has not yet elapsed
|
|
42
|
+
* as of the call time.
|
|
43
|
+
*/
|
|
47
44
|
online(): PresenceEntry<T>[] {
|
|
48
45
|
const now = Date.now();
|
|
49
46
|
const live: PresenceEntry<T>[] = [];
|
|
@@ -53,17 +50,30 @@ export class PresenceHandle<T> {
|
|
|
53
50
|
return live;
|
|
54
51
|
}
|
|
55
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Registers a listener that is called whenever the online entries change.
|
|
55
|
+
*
|
|
56
|
+
* @returns An unsubscribe function — call it to stop receiving updates.
|
|
57
|
+
*/
|
|
56
58
|
onChange(listener: (entries: PresenceEntry<T>[]) => void): () => void {
|
|
57
59
|
this.listeners.add(listener);
|
|
58
60
|
return () => { this.listeners.delete(listener); };
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Broadcasts a heartbeat that marks the current client as online for `ttlMs`
|
|
65
|
+
* milliseconds (default `30 000`).
|
|
66
|
+
*
|
|
67
|
+
* Call this periodically (at least once per `ttlMs`) to remain visible to
|
|
68
|
+
* other clients. The `usePresence` hook manages this automatically when
|
|
69
|
+
* `opts.data` is provided.
|
|
70
|
+
*
|
|
71
|
+
* @param data - Arbitrary payload broadcast to other clients.
|
|
72
|
+
* @param ttlMs - Time-to-live in milliseconds before the entry is considered stale.
|
|
73
|
+
*/
|
|
64
74
|
heartbeat(data: T, ttlMs: number = 30_000): void {
|
|
65
75
|
const wallMs = Date.now();
|
|
66
|
-
const hlc = { wall_ms:
|
|
76
|
+
const hlc = { wall_ms: wallMs, logical: 0, node_id: this.clientId };
|
|
67
77
|
|
|
68
78
|
this.entries.set(String(this.clientId), {
|
|
69
79
|
clientId: this.clientId,
|
|
@@ -82,10 +92,15 @@ export class PresenceHandle<T> {
|
|
|
82
92
|
});
|
|
83
93
|
}
|
|
84
94
|
|
|
85
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Marks the current client as offline and broadcasts a leave operation.
|
|
97
|
+
*
|
|
98
|
+
* After calling this, the client's entry is removed from the `online()` list
|
|
99
|
+
* on all peers. The `usePresence` hook calls this automatically on unmount.
|
|
100
|
+
*/
|
|
86
101
|
leave(): void {
|
|
87
102
|
const wallMs = Date.now();
|
|
88
|
-
const hlc = { wall_ms:
|
|
103
|
+
const hlc = { wall_ms: wallMs, logical: 0, node_id: this.clientId };
|
|
89
104
|
|
|
90
105
|
this.entries.delete(String(this.clientId));
|
|
91
106
|
this.emit();
|
|
@@ -98,8 +113,6 @@ export class PresenceHandle<T> {
|
|
|
98
113
|
});
|
|
99
114
|
}
|
|
100
115
|
|
|
101
|
-
// ---- Delta application ----
|
|
102
|
-
|
|
103
116
|
applyDelta(delta: PresenceDelta): void {
|
|
104
117
|
let changed = false;
|
|
105
118
|
for (const [clientIdStr, entry] of Object.entries(delta.changes)) {
|
|
@@ -115,8 +128,6 @@ export class PresenceHandle<T> {
|
|
|
115
128
|
if (changed) this.emit();
|
|
116
129
|
}
|
|
117
130
|
|
|
118
|
-
// ---- Internal ----
|
|
119
|
-
|
|
120
131
|
private decode(raw: unknown): T {
|
|
121
132
|
if (this.schema !== null) {
|
|
122
133
|
return Schema.decodeUnknownSync(this.schema)(raw);
|
|
@@ -157,6 +168,6 @@ export class PresenceHandle<T> {
|
|
|
157
168
|
|
|
158
169
|
private emit(): void {
|
|
159
170
|
const live = this.online();
|
|
160
|
-
for (const
|
|
171
|
+
for (const listener of this.listeners) listener(live);
|
|
161
172
|
}
|
|
162
173
|
}
|
package/src/errors.ts
CHANGED
|
@@ -1,24 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Typed errors for the Meridian SDK — all extend Data.TaggedError so they
|
|
3
|
-
* can be matched with Effect.catchTag / Effect.catchAll.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
1
|
import { Data } from "effect";
|
|
7
2
|
import type { ErrorResponse } from "./schema.js";
|
|
8
3
|
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Codec
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
4
|
export class CodecError extends Data.TaggedError("CodecError")<{
|
|
14
5
|
readonly message: string;
|
|
15
6
|
readonly raw: Uint8Array;
|
|
16
7
|
}> {}
|
|
17
8
|
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Auth / Token
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
9
|
export class TokenParseError extends Data.TaggedError("TokenParseError")<{
|
|
23
10
|
readonly message: string;
|
|
24
11
|
}> {}
|
|
@@ -27,10 +14,6 @@ export class TokenExpiredError extends Data.TaggedError("TokenExpiredError")<{
|
|
|
27
14
|
readonly expiredAt: number;
|
|
28
15
|
}> {}
|
|
29
16
|
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// HTTP
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
|
|
34
17
|
export class HttpError extends Data.TaggedError("HttpError")<{
|
|
35
18
|
readonly status: number;
|
|
36
19
|
readonly body: ErrorResponse;
|
|
@@ -41,10 +24,6 @@ export class NetworkError extends Data.TaggedError("NetworkError")<{
|
|
|
41
24
|
readonly cause?: unknown;
|
|
42
25
|
}> {}
|
|
43
26
|
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
// WebSocket / Transport
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
|
|
48
27
|
export class TransportError extends Data.TaggedError("TransportError")<{
|
|
49
28
|
readonly message: string;
|
|
50
29
|
readonly cause?: unknown;
|
package/src/schema.ts
CHANGED
|
@@ -7,34 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
import { Schema } from "effect";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
// Shared primitives
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Unix timestamp in milliseconds.
|
|
16
|
-
* msgpackr decodes Rust u64 as BigInt — accept both for compatibility.
|
|
17
|
-
*/
|
|
10
|
+
/** Unix timestamp in milliseconds. Rust u64 decodes as bigint when > Number.MAX_SAFE_INTEGER — normalise to number. */
|
|
18
11
|
export const TimestampMs = Schema.Union(Schema.Number, Schema.BigIntFromSelf.pipe(Schema.transform(
|
|
19
12
|
Schema.Number,
|
|
20
13
|
{ decode: (n) => Number(n), encode: (n) => BigInt(n) },
|
|
21
14
|
)));
|
|
22
15
|
export type TimestampMs = number;
|
|
23
16
|
|
|
24
|
-
/**
|
|
25
|
-
* client_id / author — Rust u64, decoded by msgpackr as BigInt or number.
|
|
26
|
-
* Accept both and normalise to number.
|
|
27
|
-
*/
|
|
17
|
+
/** client_id / author — Rust u64, may decode as bigint for large values. Normalise to number. */
|
|
28
18
|
export const ClientId = Schema.Union(Schema.Number, Schema.BigIntFromSelf.pipe(Schema.transform(
|
|
29
19
|
Schema.Number,
|
|
30
20
|
{ decode: (n) => Number(n), encode: (n) => BigInt(n) },
|
|
31
21
|
)));
|
|
32
22
|
export type ClientId = number;
|
|
33
23
|
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Auth / Token claims
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
24
|
export const Permissions = Schema.Struct({
|
|
39
25
|
read: Schema.Array(Schema.String),
|
|
40
26
|
write: Schema.Array(Schema.String),
|
|
@@ -50,18 +36,10 @@ export const TokenClaims = Schema.Struct({
|
|
|
50
36
|
});
|
|
51
37
|
export type TokenClaims = typeof TokenClaims.Type;
|
|
52
38
|
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Vector clock
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
39
|
/** BTreeMap<client_id, version> — matches server VectorClock.entries */
|
|
58
40
|
export const VectorClock = Schema.Record({ key: Schema.String, value: Schema.Number });
|
|
59
41
|
export type VectorClock = typeof VectorClock.Type;
|
|
60
42
|
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Client → Server WebSocket messages
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
43
|
export const ClientMsg = Schema.Union(
|
|
66
44
|
Schema.Struct({ Subscribe: Schema.Struct({ crdt_id: Schema.String }) }),
|
|
67
45
|
Schema.Struct({
|
|
@@ -73,22 +51,11 @@ export const ClientMsg = Schema.Union(
|
|
|
73
51
|
);
|
|
74
52
|
export type ClientMsg = typeof ClientMsg.Type;
|
|
75
53
|
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Server → Client WebSocket messages
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
54
|
export const ServerMsg = Schema.Union(
|
|
81
55
|
Schema.Struct({
|
|
82
56
|
Delta: Schema.Struct({
|
|
83
57
|
crdt_id: Schema.String,
|
|
84
|
-
|
|
85
|
-
delta_bytes: Schema.Union(
|
|
86
|
-
Schema.Uint8ArrayFromSelf,
|
|
87
|
-
Schema.Array(Schema.Number).pipe(Schema.transform(Schema.Uint8ArrayFromSelf, {
|
|
88
|
-
decode: (arr) => new Uint8Array(arr),
|
|
89
|
-
encode: (u8) => Array.from(u8),
|
|
90
|
-
})),
|
|
91
|
-
),
|
|
58
|
+
delta_bytes: Schema.Uint8ArrayFromSelf,
|
|
92
59
|
}),
|
|
93
60
|
}),
|
|
94
61
|
Schema.Struct({ Ack: Schema.Struct({ seq: Schema.Number }) }),
|
|
@@ -98,10 +65,6 @@ export const ServerMsg = Schema.Union(
|
|
|
98
65
|
);
|
|
99
66
|
export type ServerMsg = typeof ServerMsg.Type;
|
|
100
67
|
|
|
101
|
-
// ---------------------------------------------------------------------------
|
|
102
|
-
// CRDT value types (returned by GET /v1/namespaces/:ns/crdts/:id)
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
|
|
105
68
|
export const GCounterValue = Schema.Struct({
|
|
106
69
|
value: Schema.Number,
|
|
107
70
|
counts: Schema.Record({ key: Schema.String, value: Schema.Number }),
|
|
@@ -136,10 +99,6 @@ export const PresenceValue = Schema.Struct({
|
|
|
136
99
|
});
|
|
137
100
|
export type PresenceValue = typeof PresenceValue.Type;
|
|
138
101
|
|
|
139
|
-
// ---------------------------------------------------------------------------
|
|
140
|
-
// HTTP response envelopes
|
|
141
|
-
// ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
102
|
/** GET /v1/namespaces/:ns/crdts/:id — server returns the raw JSON value directly. */
|
|
144
103
|
export const CrdtGetResponse = Schema.Unknown;
|
|
145
104
|
export type CrdtGetResponse = unknown;
|
package/src/sync/clock.ts
CHANGED
|
@@ -1,25 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client-side VectorClock.
|
|
3
|
-
*
|
|
4
|
-
* Tracks the latest version seen per client_id. Used to request
|
|
5
|
-
* delta-since on reconnect (Sync message).
|
|
6
|
-
*
|
|
7
|
-
* Persisted to localStorage (browser) or a JSON file (Node/Bun) via
|
|
8
|
-
* the optional `ClockStorage` adapter injected at construction.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import type { VectorClock } from "../schema.js";
|
|
12
2
|
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// ClockStorage adapter
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
|
|
17
3
|
export interface ClockStorage {
|
|
18
4
|
load(key: string): VectorClock | null;
|
|
19
5
|
save(key: string, vc: VectorClock): void;
|
|
20
6
|
}
|
|
21
7
|
|
|
22
|
-
/** In-memory only — useful for tests and SSR. */
|
|
23
8
|
export const memoryStorage: ClockStorage = (() => {
|
|
24
9
|
const store = new Map<string, VectorClock>();
|
|
25
10
|
return {
|
|
@@ -28,39 +13,26 @@ export const memoryStorage: ClockStorage = (() => {
|
|
|
28
13
|
};
|
|
29
14
|
})();
|
|
30
15
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// VectorClockTracker
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
16
|
+
export const localStorageAdapter = (prefix = "meridian:"): ClockStorage => ({
|
|
17
|
+
load(key) {
|
|
18
|
+
try {
|
|
19
|
+
const raw = localStorage.getItem(`${prefix}${key}`);
|
|
20
|
+
if (raw === null) return null;
|
|
21
|
+
return JSON.parse(raw) as VectorClock;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
save(key, vc) {
|
|
27
|
+
try {
|
|
28
|
+
localStorage.setItem(`${prefix}${key}`, JSON.stringify(vc));
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
56
33
|
|
|
57
|
-
/**
|
|
58
|
-
* Mutable wrapper around VectorClock that tracks seen versions and
|
|
59
|
-
* optionally persists them across page reloads.
|
|
60
|
-
*/
|
|
61
34
|
export class VectorClockTracker {
|
|
62
|
-
//
|
|
63
|
-
// keep a plain mutable Record internally and expose snapshots.
|
|
35
|
+
// HACK: VectorClock (Schema type) is readonly, so we keep a plain mutable Record internally and expose snapshots.
|
|
64
36
|
private readonly clock: Record<string, number>;
|
|
65
37
|
private readonly storage: ClockStorage | null;
|
|
66
38
|
private readonly storageKey: string;
|
|
@@ -78,7 +50,6 @@ export class VectorClockTracker {
|
|
|
78
50
|
this.clock = { ...(opts.initial ?? {}), ...(persisted ?? {}) };
|
|
79
51
|
}
|
|
80
52
|
|
|
81
|
-
/** Observe a version from a peer. Updates clock if newer. */
|
|
82
53
|
observe(clientId: string, version: number): void {
|
|
83
54
|
const current = this.clock[clientId] ?? 0;
|
|
84
55
|
if (version > current) {
|
|
@@ -87,7 +58,6 @@ export class VectorClockTracker {
|
|
|
87
58
|
}
|
|
88
59
|
}
|
|
89
60
|
|
|
90
|
-
/** Merge an entire remote VectorClock (take max per entry). */
|
|
91
61
|
merge(remote: VectorClock): void {
|
|
92
62
|
let changed = false;
|
|
93
63
|
for (const [id, ver] of Object.entries(remote)) {
|
|
@@ -102,12 +72,10 @@ export class VectorClockTracker {
|
|
|
102
72
|
}
|
|
103
73
|
}
|
|
104
74
|
|
|
105
|
-
/** Returns an immutable snapshot suitable for the Sync message. */
|
|
106
75
|
snapshot(): VectorClock {
|
|
107
76
|
return { ...this.clock };
|
|
108
77
|
}
|
|
109
78
|
|
|
110
|
-
/** Reset to empty (e.g. when the namespace is cleared). */
|
|
111
79
|
reset(): void {
|
|
112
80
|
for (const key of Object.keys(this.clock)) {
|
|
113
81
|
delete this.clock[key];
|
package/src/sync/delta.ts
CHANGED
|
@@ -1,40 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
* Delta application helpers.
|
|
3
|
-
*
|
|
4
|
-
* The server sends deltas as opaque msgpack bytes inside `ServerMsg.Delta`.
|
|
5
|
-
* Each CRDT type decodes its own delta format matching Rust serde output.
|
|
6
|
-
*
|
|
7
|
-
* Field names mirror the Rust structs exactly (rmp-serde named encoding).
|
|
8
|
-
*/
|
|
1
|
+
import { decode } from "@msgpack/msgpack";
|
|
9
2
|
|
|
10
|
-
import { unpack } from "msgpackr";
|
|
11
|
-
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// GCounter delta
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
16
|
-
/** GCounterDelta { counters: BTreeMap<u64, u64> } */
|
|
17
3
|
export interface GCounterDelta {
|
|
18
4
|
counters: Record<string, number>;
|
|
19
5
|
}
|
|
20
6
|
|
|
21
|
-
export
|
|
22
|
-
const raw =
|
|
7
|
+
export const decodeGCounterDelta = (bytes: Uint8Array): GCounterDelta => {
|
|
8
|
+
const raw = decode(bytes) as { counters?: Record<string, number> };
|
|
23
9
|
return { counters: raw.counters ?? {} };
|
|
24
|
-
}
|
|
10
|
+
};
|
|
25
11
|
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// PNCounter delta
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
/** PNCounterDelta { pos: Option<GCounterDelta>, neg: Option<GCounterDelta> } */
|
|
31
12
|
export interface PNCounterDelta {
|
|
32
13
|
pos: GCounterDelta | null;
|
|
33
14
|
neg: GCounterDelta | null;
|
|
34
15
|
}
|
|
35
16
|
|
|
36
|
-
export
|
|
37
|
-
const raw =
|
|
17
|
+
export const decodePNCounterDelta = (bytes: Uint8Array): PNCounterDelta => {
|
|
18
|
+
const raw = decode(bytes) as {
|
|
38
19
|
pos?: { counters?: Record<string, number> } | null;
|
|
39
20
|
neg?: { counters?: Record<string, number> } | null;
|
|
40
21
|
};
|
|
@@ -42,57 +23,39 @@ export function decodePNCounterDelta(bytes: Uint8Array): PNCounterDelta {
|
|
|
42
23
|
pos: raw.pos ? { counters: raw.pos.counters ?? {} } : null,
|
|
43
24
|
neg: raw.neg ? { counters: raw.neg.counters ?? {} } : null,
|
|
44
25
|
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// ORSet delta
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
26
|
+
};
|
|
50
27
|
|
|
51
|
-
/**
|
|
52
|
-
* ORSetDelta { adds: HashMap<String, HashSet<Uuid>>, removes: HashMap<String, HashSet<Uuid>> }
|
|
53
|
-
* Uuid bytes are decoded by msgpackr as Uint8Array or number[].
|
|
54
|
-
*/
|
|
55
28
|
export interface ORSetDelta {
|
|
56
29
|
adds: Record<string, Uint8Array[]>;
|
|
57
30
|
removes: Record<string, Uint8Array[]>;
|
|
58
31
|
}
|
|
59
32
|
|
|
60
|
-
export
|
|
61
|
-
const raw =
|
|
33
|
+
export const decodeORSetDelta = (bytes: Uint8Array): ORSetDelta => {
|
|
34
|
+
const raw = decode(bytes) as {
|
|
62
35
|
adds?: Record<string, unknown[]>;
|
|
63
36
|
removes?: Record<string, unknown[]>;
|
|
64
37
|
};
|
|
65
|
-
const toBytes = (
|
|
66
|
-
|
|
67
|
-
const decodeMap = (
|
|
68
|
-
Object.fromEntries(Object.entries(
|
|
38
|
+
const toBytes = (value: unknown): Uint8Array =>
|
|
39
|
+
value instanceof Uint8Array ? value : new Uint8Array(value as number[]);
|
|
40
|
+
const decodeMap = (map?: Record<string, unknown[]>) =>
|
|
41
|
+
Object.fromEntries(Object.entries(map ?? {}).map(([key, tags]) => [key, tags.map(toBytes)]));
|
|
69
42
|
return { adds: decodeMap(raw.adds), removes: decodeMap(raw.removes) };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
// LWW Register delta
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
43
|
+
};
|
|
75
44
|
|
|
76
|
-
/** LwwEntry { value, hlc: HybridLogicalClock, author: u64 } */
|
|
77
45
|
export interface LwwEntry {
|
|
78
46
|
value: unknown;
|
|
79
47
|
hlc: { wall_ms: number | bigint; logical: number; node_id: number | bigint };
|
|
80
48
|
author: number | bigint;
|
|
81
49
|
}
|
|
82
50
|
|
|
83
|
-
/** LwwDelta { entry: Option<LwwEntry> } */
|
|
84
51
|
export interface LwwDelta {
|
|
85
52
|
entry: LwwEntry | null;
|
|
86
53
|
}
|
|
87
54
|
|
|
88
|
-
export
|
|
89
|
-
const raw =
|
|
55
|
+
export const decodeLwwDelta = (bytes: Uint8Array): LwwDelta => {
|
|
56
|
+
const raw = decode(bytes) as { entry?: LwwEntry | null };
|
|
90
57
|
return { entry: raw.entry ?? null };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Presence delta
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
58
|
+
};
|
|
96
59
|
|
|
97
60
|
export interface PresenceEntryDelta {
|
|
98
61
|
data: unknown;
|
|
@@ -100,12 +63,11 @@ export interface PresenceEntryDelta {
|
|
|
100
63
|
ttl_ms: number | bigint;
|
|
101
64
|
}
|
|
102
65
|
|
|
103
|
-
/** PresenceDelta — changes per client_id */
|
|
104
66
|
export interface PresenceDelta {
|
|
105
67
|
changes: Record<string, PresenceEntryDelta | null>;
|
|
106
68
|
}
|
|
107
69
|
|
|
108
|
-
export
|
|
109
|
-
const raw =
|
|
70
|
+
export const decodePresenceDelta = (bytes: Uint8Array): PresenceDelta => {
|
|
71
|
+
const raw = decode(bytes) as { changes?: Record<string, PresenceEntryDelta | null> };
|
|
110
72
|
return { changes: raw.changes ?? {} };
|
|
111
|
-
}
|
|
73
|
+
};
|
package/src/transport/http.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP transport — REST API client for Meridian.
|
|
3
|
-
*
|
|
4
|
-
* All methods return Effect<T, HttpError | NetworkError> so callers
|
|
5
|
-
* can handle network failures and server errors as typed values.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { encode, decode } from "../codec.js";
|
|
9
2
|
import { Effect, Schema } from "effect";
|
|
10
3
|
import { HttpError, NetworkError } from "../errors.js";
|
|
@@ -13,27 +6,16 @@ import {
|
|
|
13
6
|
CrdtOpResponse,
|
|
14
7
|
TokenIssueResponse,
|
|
15
8
|
ErrorResponse,
|
|
16
|
-
VectorClock,
|
|
9
|
+
type VectorClock,
|
|
17
10
|
} from "../schema.js";
|
|
18
11
|
import type { Permissions } from "../schema.js";
|
|
19
12
|
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Config
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
13
|
export interface HttpClientConfig {
|
|
25
|
-
/** Base URL of the Meridian server, e.g. "http://localhost:3000" */
|
|
26
14
|
baseUrl: string;
|
|
27
|
-
/** Bearer token. Can be updated after construction (e.g. after refresh). */
|
|
28
15
|
token: string;
|
|
29
|
-
/** Request timeout in ms. Default: 10_000 */
|
|
30
16
|
timeoutMs?: number;
|
|
31
17
|
}
|
|
32
18
|
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// HttpClient
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
19
|
export class HttpClient {
|
|
38
20
|
private readonly baseUrl: string;
|
|
39
21
|
private readonly timeoutMs: number;
|
|
@@ -45,19 +27,14 @@ export class HttpClient {
|
|
|
45
27
|
this.timeoutMs = config.timeoutMs ?? 10_000;
|
|
46
28
|
}
|
|
47
29
|
|
|
48
|
-
// ---- CRDT REST ----
|
|
49
|
-
|
|
50
|
-
/** GET /v1/namespaces/:ns/crdts/:id */
|
|
51
30
|
getCrdt(ns: string, id: string): Effect.Effect<CrdtGetResponse, HttpError | NetworkError> {
|
|
52
31
|
return this.request(CrdtGetResponse, "GET", `/v1/namespaces/${ns}/crdts/${id}`);
|
|
53
32
|
}
|
|
54
33
|
|
|
55
|
-
/** POST /v1/namespaces/:ns/crdts/:id/ops */
|
|
56
34
|
postOp(ns: string, id: string, op: unknown): Effect.Effect<CrdtOpResponse, HttpError | NetworkError> {
|
|
57
35
|
return this.request(CrdtOpResponse, "POST", `/v1/namespaces/${ns}/crdts/${id}/ops`, op);
|
|
58
36
|
}
|
|
59
37
|
|
|
60
|
-
/** GET /v1/namespaces/:ns/crdts/:id/sync?since=<base64url(msgpack(vc))> */
|
|
61
38
|
syncCrdt(
|
|
62
39
|
ns: string,
|
|
63
40
|
id: string,
|
|
@@ -71,7 +48,6 @@ export class HttpClient {
|
|
|
71
48
|
return this.request(CrdtGetResponse, "GET", path);
|
|
72
49
|
}
|
|
73
50
|
|
|
74
|
-
/** POST /v1/namespaces/:ns/tokens (admin-only) */
|
|
75
51
|
issueToken(
|
|
76
52
|
ns: string,
|
|
77
53
|
opts: { client_id: number; ttl_ms: number; permissions: Permissions },
|
|
@@ -79,8 +55,6 @@ export class HttpClient {
|
|
|
79
55
|
return this.request(TokenIssueResponse, "POST", `/v1/namespaces/${ns}/tokens`, opts);
|
|
80
56
|
}
|
|
81
57
|
|
|
82
|
-
// ---- Internal ----
|
|
83
|
-
|
|
84
58
|
private request<A, I>(
|
|
85
59
|
responseSchema: Schema.Schema<A, I>,
|
|
86
60
|
method: string,
|
|
@@ -140,14 +114,10 @@ export class HttpClient {
|
|
|
140
114
|
}
|
|
141
115
|
}
|
|
142
116
|
|
|
143
|
-
|
|
144
|
-
// base64url (no-padding) encoder
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
|
|
147
|
-
function base64urlEncode(bytes: Uint8Array): string {
|
|
117
|
+
const base64urlEncode = (bytes: Uint8Array): string => {
|
|
148
118
|
let binary = "";
|
|
149
119
|
for (const b of bytes) {
|
|
150
120
|
binary += String.fromCharCode(b);
|
|
151
121
|
}
|
|
152
122
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
153
|
-
}
|
|
123
|
+
};
|