@lyku/para-sync 0.0.1-pre.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.
- package/README.md +109 -0
- package/package.json +33 -0
- package/src/client.js +175 -0
- package/src/index.js +13 -0
- package/src/transport.js +260 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# @lyku/para-sync
|
|
2
|
+
|
|
3
|
+
`synced<T>` distributed object sync for the para:\* suite — server-authoritative
|
|
4
|
+
records with live, version-reconciled client replicas over the existing
|
|
5
|
+
single-WS-per-browser objectfeed.
|
|
6
|
+
|
|
7
|
+
> **Pre-release (0.0.1-pre).** API will change before 0.1.0. This package
|
|
8
|
+
> currently ships the **transport layer** (`InProcessTransport` +
|
|
9
|
+
> `NatsTransport`) and the **client-side reconciler**. The full `synced<T>`
|
|
10
|
+
> primitive (server resolver + SSR plumbing) and the server-write `::` gate land
|
|
11
|
+
> on top — those touch Postgres/Valkey and the schema-version layer, so they
|
|
12
|
+
> live closer to the app.
|
|
13
|
+
|
|
14
|
+
## What's here today: the transport layer
|
|
15
|
+
|
|
16
|
+
`synced<T>` must not couple to any one message bus. It depends only on the
|
|
17
|
+
`SyncTransport` interface; the concrete transport is chosen by deployment config.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { InProcessTransport } from "@lyku/para-sync";
|
|
21
|
+
|
|
22
|
+
const transport = new InProcessTransport();
|
|
23
|
+
|
|
24
|
+
// listen handler (e.g. a WS-stream handler) subscribes for a key
|
|
25
|
+
const off = transport.subscribe("user:123", (envelope) => {
|
|
26
|
+
// envelope = { value, schema_version, sequence }
|
|
27
|
+
// parse-gate + version-check + apply happen in the consumer, not here
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// write handler publishes the change envelope after persisting
|
|
31
|
+
transport.publish("user:123", { value: updatedUser, schema_version: "3.1", sequence: 42 });
|
|
32
|
+
|
|
33
|
+
off(); // idempotent unsubscribe
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `SyncTransport` contract
|
|
37
|
+
|
|
38
|
+
| member | behavior |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `publish(key, envelope)` | delivers `envelope` to every current subscriber of `key`, in subscription order; no-op if none |
|
|
41
|
+
| `subscribe(key, handler)` | registers `handler`; returns an idempotent `Unsub` |
|
|
42
|
+
|
|
43
|
+
The transport is a **dumb pipe**: it does not retain the latest value, does not
|
|
44
|
+
validate the envelope (`parse` gating is the consumer's job at the apply
|
|
45
|
+
boundary), and does not dedupe by sequence. A subscriber receives only publishes
|
|
46
|
+
that happen **after** it subscribes — initial state arrives via the SSR seed.
|
|
47
|
+
|
|
48
|
+
### Implementations
|
|
49
|
+
|
|
50
|
+
- **`InProcessTransport`** (here) — monolith / edge / IoT / all-in-one, where the
|
|
51
|
+
write handler and listen handlers share a process and there is no inter-service
|
|
52
|
+
bus. A keyed `Map<key, Set<handler>>` emitter; delivery is a synchronous call.
|
|
53
|
+
Empty key entries are GC'd when their last subscriber leaves (`keyCount()` is a
|
|
54
|
+
leak-check diagnostic).
|
|
55
|
+
- **`NatsTransport`** — multi-service deployments; the change crosses services
|
|
56
|
+
over NATS, matching Lyku's existing full-object-over-NATS convention. Inject a
|
|
57
|
+
`connection` (callback-adapted: `publish(subject, bytes)` /
|
|
58
|
+
`subscribe(subject, onMessage) → unsub`), a wire `codec` (BON/msgpackr in
|
|
59
|
+
production — bigint IDs rule out JSON), and an optional `subjectOf(key)`.
|
|
60
|
+
N local subscribers to one key share a single bus subscription (local fanout),
|
|
61
|
+
torn down when the last leaves.
|
|
62
|
+
|
|
63
|
+
The **client side is identical** across transports: WS receive → `parse` →
|
|
64
|
+
version-check → apply. Only the server-internal delivery path differs.
|
|
65
|
+
|
|
66
|
+
## Client reconciler
|
|
67
|
+
|
|
68
|
+
`createClientReplica` is the client half of `synced<T>`: it takes the SSR seed
|
|
69
|
+
and the stream of envelopes, gates every inbound value through the schema
|
|
70
|
+
`parse`, reconciles by `(schema_version, sequence)`, and applies the
|
|
71
|
+
authoritative value into a reactive cell so the DOM reacts.
|
|
72
|
+
|
|
73
|
+
```js
|
|
74
|
+
import { createClientReplica, InProcessTransport } from "@lyku/para-sync";
|
|
75
|
+
|
|
76
|
+
const replica = createClientReplica({
|
|
77
|
+
key: "user:123",
|
|
78
|
+
schema: User, // anything with parse(v) => {tag:'Ok',value} | {tag:'Err',error}
|
|
79
|
+
transport, // a SyncTransport
|
|
80
|
+
seed: ssrEnvelope, // {value, schema_version, sequence} embedded in the HTML
|
|
81
|
+
refetch: () => fetchSnapshot("user:123") // Err/skew/gap fallback → current snapshot
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
replica.get(); // current value, tracked (read inside an effect/template → reacts)
|
|
85
|
+
replica.meta(); // { schemaVersion, sequence, status }, tracked
|
|
86
|
+
replica.dispose();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Reconcile rules (Tier 1):
|
|
90
|
+
|
|
91
|
+
- **parse gate** on every inbound value (SSR seed, receipt, refetch). `Err` →
|
|
92
|
+
status `skew`, the cell is **not** poisoned, and a `refetch` recovers a
|
|
93
|
+
known-good snapshot. Gates branch on `.tag` — they never throw (that is why
|
|
94
|
+
`::`, which throws on `Err`, is reserved for the server-write gate only).
|
|
95
|
+
- **baseline (re)seed** — hydration, a recovery refetch, or the first value ever
|
|
96
|
+
seen is accepted unconditionally as the authoritative base.
|
|
97
|
+
- **steady-state receipt** — apply iff `sequence === current + 1`; `<= current`
|
|
98
|
+
is ignored (stale/duplicate/out-of-order); `> current + 1` is a gap → refetch
|
|
99
|
+
+ resync.
|
|
100
|
+
|
|
101
|
+
`replica.stats` exposes counters (`applied`, `ignoredStale`, `gaps`,
|
|
102
|
+
`parseErrors`, `refetches`); `replica.whenIdle()` resolves when no recovery
|
|
103
|
+
refetch is in flight (a test/await aid).
|
|
104
|
+
|
|
105
|
+
## Test
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
bun test # from packages/para-sync
|
|
109
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lyku/para-sync",
|
|
3
|
+
"version": "0.0.1-pre.0",
|
|
4
|
+
"description": "synced<T> distributed object sync for the para:* suite — server-authoritative records with live, version-reconciled client replicas. Pre-release: API may change before 0.1.0. Currently provides the pluggable SyncTransport interface and the InProcessTransport implementation (monolith/edge/no-bus deployments); NatsTransport and the synced<T> primitive land on top.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"module": "./src/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@lyku/para-signals": "^0.0.1-pre.0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/airgap/parabun.git",
|
|
25
|
+
"directory": "packages/para-sync"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"sync",
|
|
29
|
+
"replication",
|
|
30
|
+
"reactive",
|
|
31
|
+
"para"
|
|
32
|
+
]
|
|
33
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @lyku/para-sync — client-side replica reconciler (Tier 1 core).
|
|
2
|
+
//
|
|
3
|
+
// The heart of the client half of synced<T>: take the SSR seed and the stream
|
|
4
|
+
// of change envelopes, gate every inbound value through the schema `parse`,
|
|
5
|
+
// reconcile by (schema_version, sequence), and apply the authoritative value
|
|
6
|
+
// into a reactive cell so the DOM reacts.
|
|
7
|
+
//
|
|
8
|
+
// What this is NOT: it does not open the WS, does not run the connect-time
|
|
9
|
+
// handshake (Tier 1 step 4), and does not write (Tier 2). It is the pure
|
|
10
|
+
// receive → parse → version-check → apply engine, transport-agnostic.
|
|
11
|
+
|
|
12
|
+
import { signal } from "@lyku/para-signals";
|
|
13
|
+
|
|
14
|
+
/** @typedef {import('./transport.js').SyncEnvelope} SyncEnvelope */
|
|
15
|
+
/** @typedef {import('./transport.js').SyncTransport} SyncTransport */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A schema's parse result — matches para-schema's Result<T, string>.
|
|
19
|
+
* @typedef {{ tag: 'Ok', value: any } | { tag: 'Err', error: string }} Result
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Anything with a `parse` returning {@link Result}. In production this is a
|
|
24
|
+
* para-schema `SchemaValue`; in tests it can be a hand-rolled gate. The replica
|
|
25
|
+
* depends only on this shape, never on para-schema directly — which is also why
|
|
26
|
+
* the client gates branch on `.tag` instead of using the throw-on-Err `::`
|
|
27
|
+
* convention (a malformed delta must trigger recovery, not crash the apply).
|
|
28
|
+
* @typedef {{ parse(v: unknown): Result }} SyncSchema
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A minimal reactive value cell: get / peek / set. A para-signals `signal()`
|
|
33
|
+
* satisfies it exactly and is the default. Injectable for testing and for the
|
|
34
|
+
* fork-backed cell on the para-svelte side.
|
|
35
|
+
* @typedef {{ get(): any, peek(): any, set(v: any): void }} Cell
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {'ok' | 'stale' | 'skew' | 'refetching'} ReplicaStatus
|
|
40
|
+
* - ok last apply succeeded; replica is current
|
|
41
|
+
* - stale uninitialized, or a refetch failed / none available
|
|
42
|
+
* - skew an inbound value failed `parse` (malformed or schema-skew)
|
|
43
|
+
* - refetching a recovery refetch is in flight
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} ReplicaMeta
|
|
48
|
+
* @property {string | null} schemaVersion schema version of the applied value
|
|
49
|
+
* @property {number} sequence sequence of the applied value (-1 if uninitialized)
|
|
50
|
+
* @property {ReplicaStatus} status
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a client replica for one synced key.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} opts.key synced key, e.g. "user:123"
|
|
58
|
+
* @param {SyncSchema} opts.schema the `parse` gate
|
|
59
|
+
* @param {SyncTransport} opts.transport change-envelope source
|
|
60
|
+
* @param {SyncEnvelope} [opts.seed] SSR-embedded initial envelope
|
|
61
|
+
* @param {() => Promise<SyncEnvelope>} [opts.refetch] Err/skew/gap fallback: fetch the
|
|
62
|
+
* current authoritative snapshot. Omit → no recovery (status goes 'stale').
|
|
63
|
+
* @param {Cell} [opts.cell] reactive cell (default: a para signal)
|
|
64
|
+
*/
|
|
65
|
+
export function createClientReplica({ key, schema, transport, seed, refetch, cell }) {
|
|
66
|
+
const value = cell ?? signal(undefined);
|
|
67
|
+
/** @type {Cell} */
|
|
68
|
+
const meta = signal(
|
|
69
|
+
/** @type {ReplicaMeta} */ ({ schemaVersion: null, sequence: -1, status: "stale" })
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
let initialized = false;
|
|
73
|
+
let disposed = false;
|
|
74
|
+
let pending = Promise.resolve();
|
|
75
|
+
|
|
76
|
+
const stats = { applied: 0, ignoredStale: 0, gaps: 0, parseErrors: 0, refetches: 0 };
|
|
77
|
+
|
|
78
|
+
/** @param {Partial<ReplicaMeta>} patch */
|
|
79
|
+
const setMeta = (patch) => meta.set({ ...meta.peek(), ...patch });
|
|
80
|
+
|
|
81
|
+
/** @param {any} parsedValue @param {SyncEnvelope} envelope */
|
|
82
|
+
function commit(parsedValue, envelope) {
|
|
83
|
+
value.set(parsedValue);
|
|
84
|
+
initialized = true;
|
|
85
|
+
setMeta({ schemaVersion: envelope.schema_version, sequence: envelope.sequence, status: "ok" });
|
|
86
|
+
stats.applied++;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function startRefetch() {
|
|
90
|
+
if (!refetch) {
|
|
91
|
+
setMeta({ status: "stale" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
stats.refetches++;
|
|
95
|
+
setMeta({ status: "refetching" });
|
|
96
|
+
pending = (async () => {
|
|
97
|
+
try {
|
|
98
|
+
const snap = await refetch();
|
|
99
|
+
if (!disposed) ingest(snap, "refetch");
|
|
100
|
+
} catch {
|
|
101
|
+
if (!disposed) setMeta({ status: "stale" });
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {SyncEnvelope} envelope
|
|
108
|
+
* @param {'hydration' | 'receipt' | 'refetch'} source
|
|
109
|
+
*/
|
|
110
|
+
function ingest(envelope, source) {
|
|
111
|
+
if (disposed) return;
|
|
112
|
+
|
|
113
|
+
// ── parse gate (every inbound value crosses a trust boundary) ──
|
|
114
|
+
const res = schema.parse(envelope.value);
|
|
115
|
+
if (res.tag !== "Ok") {
|
|
116
|
+
stats.parseErrors++;
|
|
117
|
+
setMeta({ status: "skew" });
|
|
118
|
+
// Don't poison the cell. Recover via a known-good snapshot — but never
|
|
119
|
+
// refetch in response to a refetch result (avoids an Err→refetch loop).
|
|
120
|
+
if (source !== "refetch") startRefetch();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── baseline (re)seed ──
|
|
125
|
+
// SSR hydration, a recovery refetch, or the very first value we have seen:
|
|
126
|
+
// accept unconditionally as the new authoritative baseline.
|
|
127
|
+
if (source === "hydration" || source === "refetch" || !initialized) {
|
|
128
|
+
commit(res.value, envelope);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── steady-state receipt: reconcile by sequence ──
|
|
133
|
+
const cur = meta.peek().sequence;
|
|
134
|
+
if (envelope.sequence <= cur) {
|
|
135
|
+
stats.ignoredStale++; // stale / duplicate / out-of-order
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (envelope.sequence === cur + 1) {
|
|
139
|
+
commit(res.value, envelope); // in-order
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Gap: one or more envelopes were missed. v2 step 5 → refetch the full
|
|
143
|
+
// snapshot and resync. (Under the full-object delta model the gapped
|
|
144
|
+
// envelope already carries the complete current value, so committing it
|
|
145
|
+
// directly would be correct and cheaper — a documented future optimization;
|
|
146
|
+
// we follow v2's explicit gap→refetch here.)
|
|
147
|
+
stats.gaps++;
|
|
148
|
+
startRefetch();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── wire up ──
|
|
152
|
+
const unsub = transport.subscribe(key, (envelope) => ingest(envelope, "receipt"));
|
|
153
|
+
if (seed !== undefined) ingest(seed, "hydration"); // SSR-hydration parse gate
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
/** current value (tracked read) */
|
|
157
|
+
get: () => value.get(),
|
|
158
|
+
/** current value (untracked) */
|
|
159
|
+
peek: () => value.peek(),
|
|
160
|
+
/** reconcile metadata (tracked): { schemaVersion, sequence, status } */
|
|
161
|
+
meta: () => meta.get(),
|
|
162
|
+
/** reconcile metadata (untracked) */
|
|
163
|
+
peekMeta: () => meta.peek(),
|
|
164
|
+
/** observability counters — read directly */
|
|
165
|
+
stats,
|
|
166
|
+
/** resolves when no recovery refetch is in flight (test/await aid) */
|
|
167
|
+
whenIdle: () => pending,
|
|
168
|
+
/** stop listening; idempotent */
|
|
169
|
+
dispose: () => {
|
|
170
|
+
if (disposed) return;
|
|
171
|
+
disposed = true;
|
|
172
|
+
unsub();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @lyku/para-sync — distributed object sync for the para:* suite.
|
|
2
|
+
//
|
|
3
|
+
// Public barrel. The package is split by concern:
|
|
4
|
+
// - transport.js — the pluggable SyncTransport interface + InProcessTransport
|
|
5
|
+
// (server-internal: carry a change envelope from writer to listeners).
|
|
6
|
+
// - client.js — createClientReplica: the client-side reconciler
|
|
7
|
+
// (receive → parse → version-check → apply into a reactive cell).
|
|
8
|
+
//
|
|
9
|
+
// `synced<T>(key)` (the full primitive) composes the client reconciler with the
|
|
10
|
+
// server resolver + SSR plumbing; it lands on top of these pieces.
|
|
11
|
+
|
|
12
|
+
export * from "./transport.js";
|
|
13
|
+
export * from "./client.js";
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @lyku/para-sync — distributed object sync for the para:* suite.
|
|
2
|
+
//
|
|
3
|
+
// This module currently provides the TRANSPORT layer: the server-internal
|
|
4
|
+
// mechanism that carries a synced object's change envelope from the write
|
|
5
|
+
// handler to the listen handlers. `synced<T>` (the primitive itself) and the
|
|
6
|
+
// three `parse` gates build on top of this.
|
|
7
|
+
//
|
|
8
|
+
// Transport is a PLUGGABLE interface with (initially) two implementations:
|
|
9
|
+
// - InProcessTransport — monolith / edge / IoT / all-in-one, where the write
|
|
10
|
+
// handler and the listen handlers share a process and there is no
|
|
11
|
+
// inter-service bus to stand up. (This file.)
|
|
12
|
+
// - NatsTransport — multi-service deployments, where the change crosses
|
|
13
|
+
// from the writer's service to listeners' services over NATS. (Later.)
|
|
14
|
+
//
|
|
15
|
+
// The CLIENT side is identical across both: WS receive → parse → version-check
|
|
16
|
+
// → apply. Only the server-internal "how the write reaches the listen handler"
|
|
17
|
+
// differs, selected by deployment config.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The change envelope. Delta/full-object model: the new value travels with
|
|
21
|
+
* the reconcile key, so steady-state replication is deliver → parse → apply,
|
|
22
|
+
* with no refetch round-trip (refetch is the Err/gap/skew fallback only).
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} SyncEnvelope
|
|
25
|
+
* @property {unknown} value The full changed object. NOT validated by
|
|
26
|
+
* the transport — `parse` gating is the
|
|
27
|
+
* consumer's job at the apply boundary.
|
|
28
|
+
* @property {string} schema_version Reconcile key, part 1: the model's schema
|
|
29
|
+
* version (e.g. "3.1"). Distinguishes a
|
|
30
|
+
* compatible-but-behind replica from a
|
|
31
|
+
* different/breaking shape.
|
|
32
|
+
* @property {number} sequence Reconcile key, part 2: the object's
|
|
33
|
+
* monotonic Postgres-authoritative sequence.
|
|
34
|
+
* Sole job is ordering + gap detection.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A listener for a key's change envelopes.
|
|
39
|
+
* @callback SyncHandler
|
|
40
|
+
* @param {SyncEnvelope} envelope
|
|
41
|
+
* @returns {void}
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returned by subscribe(); calling it removes the subscription. Idempotent.
|
|
46
|
+
* @callback Unsub
|
|
47
|
+
* @returns {void}
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The pluggable transport contract. `synced<T>` depends ONLY on this shape, not
|
|
52
|
+
* on any concrete transport — that decoupling is what lets the same primitive
|
|
53
|
+
* run on a single box (InProcessTransport) or across services (NatsTransport).
|
|
54
|
+
*
|
|
55
|
+
* Contract notes that every implementation must honor:
|
|
56
|
+
* - publish(key, envelope) delivers `envelope` to every current subscriber of
|
|
57
|
+
* `key`. Publishing to a key with no subscribers is a no-op (never throws).
|
|
58
|
+
* - The transport is a dumb pipe: it does NOT retain the latest value, does
|
|
59
|
+
* NOT validate the envelope, and does NOT dedupe by sequence. A subscriber
|
|
60
|
+
* receives only publishes that happen AFTER it subscribes — initial state
|
|
61
|
+
* arrives via the SSR seed, not the transport.
|
|
62
|
+
* - subscribe(key, handler) returns an idempotent Unsub.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} SyncTransport
|
|
65
|
+
* @property {(key: string, envelope: SyncEnvelope) => void} publish
|
|
66
|
+
* @property {(key: string, handler: SyncHandler) => Unsub} subscribe
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* In-process implementation of {@link SyncTransport}. A keyed pub/sub emitter:
|
|
71
|
+
* `Map<key, Set<handler>>`. No bus, no network, no serialization — the write
|
|
72
|
+
* and the listen happen in the same process, so delivery is a synchronous call.
|
|
73
|
+
*
|
|
74
|
+
* Why a plain emitter and not a para-signal per key: the transport contract is
|
|
75
|
+
* notification semantics ("deliver future publishes to this handler"), not
|
|
76
|
+
* value-cell semantics ("hand me the current value on subscribe, then deltas").
|
|
77
|
+
* Initial state is the SSR seed's job; the transport only carries changes. A
|
|
78
|
+
* keyed emitter models that exactly, without the current-value-on-subscribe and
|
|
79
|
+
* Object.is-dedupe behavior a signal would impose.
|
|
80
|
+
*
|
|
81
|
+
* @implements {SyncTransport}
|
|
82
|
+
*/
|
|
83
|
+
export class InProcessTransport {
|
|
84
|
+
constructor() {
|
|
85
|
+
/**
|
|
86
|
+
* key → set of handlers. A key is present iff it has ≥1 live subscriber;
|
|
87
|
+
* the entry is deleted when its last subscriber unsubscribes (no key leak).
|
|
88
|
+
* @type {Map<string, Set<SyncHandler>>}
|
|
89
|
+
*/
|
|
90
|
+
this._subs = new Map();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Deliver `envelope` to every current subscriber of `key`, in subscription
|
|
95
|
+
* order. No-op if `key` has no subscribers.
|
|
96
|
+
* @param {string} key
|
|
97
|
+
* @param {SyncEnvelope} envelope
|
|
98
|
+
*/
|
|
99
|
+
publish(key, envelope) {
|
|
100
|
+
const handlers = this._subs.get(key);
|
|
101
|
+
if (handlers === undefined) return;
|
|
102
|
+
// Snapshot: a handler may subscribe/unsubscribe during delivery. Iterating
|
|
103
|
+
// a copy gives well-defined semantics — handlers live at publish time each
|
|
104
|
+
// receive this envelope; a handler added mid-delivery does not.
|
|
105
|
+
for (const handler of Array.from(handlers)) handler(envelope);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Subscribe `handler` to `key`'s change envelopes. Returns an idempotent
|
|
110
|
+
* Unsub; calling it more than once is safe and will not remove a handler that
|
|
111
|
+
* was re-subscribed in between.
|
|
112
|
+
* @param {string} key
|
|
113
|
+
* @param {SyncHandler} handler
|
|
114
|
+
* @returns {Unsub}
|
|
115
|
+
*/
|
|
116
|
+
subscribe(key, handler) {
|
|
117
|
+
let handlers = this._subs.get(key);
|
|
118
|
+
if (handlers === undefined) {
|
|
119
|
+
handlers = new Set();
|
|
120
|
+
this._subs.set(key, handlers);
|
|
121
|
+
}
|
|
122
|
+
handlers.add(handler);
|
|
123
|
+
|
|
124
|
+
let active = true;
|
|
125
|
+
return () => {
|
|
126
|
+
if (!active) return; // idempotent: a second call does nothing
|
|
127
|
+
active = false;
|
|
128
|
+
const set = this._subs.get(key);
|
|
129
|
+
if (set === undefined) return;
|
|
130
|
+
set.delete(handler);
|
|
131
|
+
if (set.size === 0) this._subs.delete(key); // GC the empty key
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Diagnostic: number of keys with at least one live subscriber. Useful for
|
|
137
|
+
* leak checks (a healthy server returns to a steady key count as sessions
|
|
138
|
+
* come and go).
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
keyCount() {
|
|
142
|
+
return this._subs.size;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A NATS connection, callback-adapted. NatsTransport expects delivery as a
|
|
148
|
+
* callback + an unsubscribe, NOT nats.js's raw async-iterable subscription —
|
|
149
|
+
* the iterable→callback adaptation is a 3-line caller concern that Lyku already
|
|
150
|
+
* does (`const sub = nc.subscribe(subj); (async () => { for await (const m of
|
|
151
|
+
* sub) onMessage(m.data); })(); return () => sub.unsubscribe();`). Keeping that
|
|
152
|
+
* out of the transport makes delivery synchronous and deterministic to test.
|
|
153
|
+
*
|
|
154
|
+
* @typedef {object} SyncNatsConnection
|
|
155
|
+
* @property {(subject: string, payload: Uint8Array) => void} publish
|
|
156
|
+
* @property {(subject: string, onMessage: (payload: Uint8Array) => void) => (() => void)} subscribe
|
|
157
|
+
* subscribe to `subject`; `onMessage` is called per message with the raw
|
|
158
|
+
* payload; returns an unsubscribe function.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Wire codec: envelope ⇄ bytes. NATS payloads are `Uint8Array`. In production
|
|
163
|
+
* inject a BON or msgpackr codec (envelopes carry bigint IDs, which JSON cannot
|
|
164
|
+
* represent — BON is the SSR/wire serializer for exactly this reason). Defaults
|
|
165
|
+
* to identity (object passthrough), which works for in-memory fakes/tests but
|
|
166
|
+
* NOT a real NATS connection.
|
|
167
|
+
*
|
|
168
|
+
* @typedef {object} SyncCodec
|
|
169
|
+
* @property {(envelope: SyncEnvelope) => any} encode
|
|
170
|
+
* @property {(payload: any) => SyncEnvelope} decode
|
|
171
|
+
*/
|
|
172
|
+
|
|
173
|
+
/** @type {SyncCodec} */
|
|
174
|
+
const IDENTITY_CODEC = { encode: (e) => e, decode: (p) => p };
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* NATS implementation of {@link SyncTransport}, for multi-service deployments
|
|
178
|
+
* where a write in the writer's service must reach listeners in other services.
|
|
179
|
+
* Matches Lyku's existing full-object-over-NATS convention.
|
|
180
|
+
*
|
|
181
|
+
* Honors the same contract as InProcessTransport (deliver to current
|
|
182
|
+
* subscribers, no retention, dumb pipe, idempotent unsub). On top it adds:
|
|
183
|
+
* - subject mapping (key → NATS subject),
|
|
184
|
+
* - the wire codec (encode on publish, decode on receipt),
|
|
185
|
+
* - LOCAL FANOUT: N local subscribers to one key share ONE bus subscription,
|
|
186
|
+
* and that bus subscription is torn down when the last local subscriber
|
|
187
|
+
* leaves (the cross-service analog of subscriber-set GC).
|
|
188
|
+
*
|
|
189
|
+
* @implements {SyncTransport}
|
|
190
|
+
*/
|
|
191
|
+
export class NatsTransport {
|
|
192
|
+
/**
|
|
193
|
+
* @param {object} opts
|
|
194
|
+
* @param {SyncNatsConnection} opts.connection
|
|
195
|
+
* @param {SyncCodec} [opts.codec] default: identity (tests only)
|
|
196
|
+
* @param {(key: string) => string} [opts.subjectOf] default: `synced.${key}`
|
|
197
|
+
*/
|
|
198
|
+
constructor({ connection, codec, subjectOf }) {
|
|
199
|
+
if (!connection) throw new Error("NatsTransport requires a connection");
|
|
200
|
+
this._nc = connection;
|
|
201
|
+
this._codec = codec ?? IDENTITY_CODEC;
|
|
202
|
+
this._subjectOf = subjectOf ?? ((key) => `synced.${key}`);
|
|
203
|
+
/**
|
|
204
|
+
* key → { natsUnsub, handlers } — present iff the key has ≥1 local
|
|
205
|
+
* subscriber (and therefore one live bus subscription).
|
|
206
|
+
* @type {Map<string, { natsUnsub: () => void, handlers: Set<SyncHandler> }>}
|
|
207
|
+
*/
|
|
208
|
+
this._keys = new Map();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @param {string} key
|
|
213
|
+
* @param {SyncEnvelope} envelope
|
|
214
|
+
*/
|
|
215
|
+
publish(key, envelope) {
|
|
216
|
+
this._nc.publish(this._subjectOf(key), this._codec.encode(envelope));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {string} key
|
|
221
|
+
* @param {SyncHandler} handler
|
|
222
|
+
* @returns {Unsub}
|
|
223
|
+
*/
|
|
224
|
+
subscribe(key, handler) {
|
|
225
|
+
let entry = this._keys.get(key);
|
|
226
|
+
if (entry === undefined) {
|
|
227
|
+
/** @type {Set<SyncHandler>} */
|
|
228
|
+
const handlers = new Set();
|
|
229
|
+
// One bus subscription per key; decode once, fan out to local handlers.
|
|
230
|
+
const natsUnsub = this._nc.subscribe(this._subjectOf(key), (payload) => {
|
|
231
|
+
const envelope = this._codec.decode(payload);
|
|
232
|
+
for (const h of Array.from(handlers)) h(envelope);
|
|
233
|
+
});
|
|
234
|
+
entry = { natsUnsub, handlers };
|
|
235
|
+
this._keys.set(key, entry);
|
|
236
|
+
}
|
|
237
|
+
entry.handlers.add(handler);
|
|
238
|
+
|
|
239
|
+
let active = true;
|
|
240
|
+
return () => {
|
|
241
|
+
if (!active) return; // idempotent
|
|
242
|
+
active = false;
|
|
243
|
+
const e = this._keys.get(key);
|
|
244
|
+
if (e === undefined) return;
|
|
245
|
+
e.handlers.delete(handler);
|
|
246
|
+
if (e.handlers.size === 0) {
|
|
247
|
+
e.natsUnsub(); // last local subscriber gone → tear down the bus sub
|
|
248
|
+
this._keys.delete(key);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Diagnostic: number of keys with a live bus subscription.
|
|
255
|
+
* @returns {number}
|
|
256
|
+
*/
|
|
257
|
+
keyCount() {
|
|
258
|
+
return this._keys.size;
|
|
259
|
+
}
|
|
260
|
+
}
|