@catalyst-cloud/sdk 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coalesce Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # @catalyst-cloud/sdk
2
+
3
+ The **isomorphic read-layer-over-WebSocket client** for the catalyst-cloud change-feed.
4
+
5
+ `catalyst-cloud` mirrors each tenant's Linear + GitHub state into a per-tenant Cloudflare Durable
6
+ Object and exposes it as a `change_log` feed. This SDK is the **push** consumer of that feed: it opens
7
+ an outbound WebSocket to a tenant's Mirror DO (`{baseUrl}/connect`), sends a cursor-replay request on
8
+ every (re)connect, and hands you each pushed change so you can land it into your own store.
9
+
10
+ It is the standalone extraction of the host daemon's live client, generalized to run **unchanged in a
11
+ browser, in Node (>=22), or in Bun**. It is the single push transport behind both the host replicas
12
+ and the web UI per [ADR-0008](https://github.com/coalesce-labs/catalyst-cloud/blob/main/docs/adr/0008-catalyst-host-local-replica-consumption.md)
13
+ (host reads from an SDK-managed per-host replica) and [ADR-0009](https://github.com/coalesce-labs/catalyst-cloud/blob/main/docs/adr/0009-browser-live-updates-over-websocket.md)
14
+ (browser live updates over a **hibernating** WebSocket, not SSE — so an idle tab can't pin the DO).
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install @catalyst-cloud/sdk
20
+ ```
21
+
22
+ Requires a platform `WebSocket` global (browser, Bun, or Node >=22). On older Node, inject a
23
+ `wsFactory` (e.g. wrapping the [`ws`](https://www.npmjs.com/package/ws) package) — the shared core has
24
+ no node-only import.
25
+
26
+ ## Design
27
+
28
+ The client is **transport-only and storage-agnostic**. It knows nothing about where your data lives
29
+ (`bun:sqlite`, OPFS, in-memory) or how you authenticate — both are **injected**:
30
+
31
+ - **`auth`** — `{ kind: "token", token }` (a trusted backend appends `?token=`; the Worker strips it
32
+ before forwarding to the DO) **or** `{ kind: "cookie" }` (the browser appends nothing; the
33
+ same-origin session cookie rides the upgrade). The cookie path can never leak a token.
34
+ - **`reseed`** — an async callback returning the fresh cursor after a full re-seed. Called on a
35
+ `{type:"resync"}` underflow frame (and before the first connect if you have no cursor). A host pulls
36
+ `/snapshot` into SQLite here; a browser re-runs its own seed.
37
+ - **`getCursor` / `onChange`** — you own the cursor and the writes; the client drives the wire.
38
+ - **`onStatus`** — connection lifecycle (`connecting` / `live` / `reconnecting` / `resyncing` /
39
+ `error` / `stopped`) so a UI can show "live" vs "reconnecting".
40
+
41
+ It reconnects with **capped exponential backoff** (1 s → ×2 → 30 s ceiling) and never throws out of
42
+ the event loop. There is **no client-side keepalive** — re-implementing the WebSocket heartbeat would
43
+ re-pin the hibernating DO, defeating ADR-0009.
44
+
45
+ ## Usage
46
+
47
+ ### Browser (cookie auth, no token)
48
+
49
+ In the browser there is **no token**: pass `auth: { kind: "cookie" }` and the same-origin session
50
+ cookie rides the WebSocket upgrade automatically. The SDK appends nothing secret to the URL, so a
51
+ token can never leak from a browser tab. Storage is yours — here it is a trivial in-memory map; in a
52
+ real app you would seed from your own `/snapshot` fetch into OPFS / IndexedDB.
53
+
54
+ ```ts
55
+ import { LiveSyncClient } from "@catalyst-cloud/sdk";
56
+
57
+ const issues = new Map<string, unknown>();
58
+ let cursor: number | null = null;
59
+
60
+ const client = new LiveSyncClient({
61
+ baseUrl: "https://app.example.com/api/v1", // same origin as the page
62
+ accountId: "tenant-0",
63
+ auth: { kind: "cookie" }, // same-origin cookie rides the upgrade; no token ever in the URL
64
+
65
+ // Full (re)seed from your own snapshot endpoint; resolve to the fresh cursor.
66
+ reseed: async () => {
67
+ const snap = (await (await fetch("/api/v1/snapshot?account=tenant-0")).json()) as {
68
+ cursor: number;
69
+ issues: { id: string }[];
70
+ };
71
+ issues.clear();
72
+ for (const row of snap.issues) issues.set(row.id, row);
73
+ cursor = snap.cursor;
74
+ return snap.cursor;
75
+ },
76
+
77
+ getCursor: () => cursor,
78
+ onChange: (frame) => {
79
+ if (frame.op === "delete") issues.delete(frame.entityId);
80
+ else issues.set(frame.entityId, frame.row);
81
+ cursor = frame.seq;
82
+ },
83
+ onStatus: (s) => setConnectionBadge(s), // "live" / "reconnecting" / …
84
+ });
85
+
86
+ void client.start(); // never await in the browser — it resolves only on stop()
87
+ // on teardown (component unmount, page hide):
88
+ client.stop();
89
+ ```
90
+
91
+ ### Backend / host daemon (token auth, `bun:sqlite` reseed)
92
+
93
+ A trusted backend authenticates with a service token (`?token=`, stripped by the Worker before it
94
+ reaches the DO) and lands every frame into a local SQLite replica. On a `{type:"resync"}` underflow
95
+ (or the very first run with no cursor) it pulls a full `/snapshot` and rewrites the table:
96
+
97
+ ```ts
98
+ import { Database } from "bun:sqlite"; // node: use better-sqlite3 with the same calls
99
+ import { LiveSyncClient } from "@catalyst-cloud/sdk";
100
+
101
+ const db = new Database("replica.sqlite");
102
+ db.run("CREATE TABLE IF NOT EXISTS issues (id TEXT PRIMARY KEY, row TEXT, seq INTEGER)");
103
+ db.run("CREATE TABLE IF NOT EXISTS sync_state (k TEXT PRIMARY KEY, cursor INTEGER)");
104
+
105
+ const baseUrl = process.env.CATALYST_CLOUD_BASE_URL!; // incl. /api/v1
106
+ const token = process.env.ADMIN_TOKEN!;
107
+
108
+ const client = new LiveSyncClient({
109
+ baseUrl,
110
+ accountId: "tenant-0",
111
+ auth: { kind: "token", token },
112
+
113
+ // Full re-seed: pull /snapshot over HTTPS and rewrite the replica in one transaction.
114
+ // Must resolve to the fresh cursor (the snapshot's max change_log.seq).
115
+ reseed: async () => {
116
+ const res = await fetch(`${baseUrl}/snapshot?account=tenant-0`, {
117
+ headers: { authorization: `Bearer ${token}` },
118
+ });
119
+ const snap = (await res.json()) as { cursor: number; issues: { id: string }[] };
120
+ const tx = db.transaction(() => {
121
+ db.run("DELETE FROM issues");
122
+ const ins = db.prepare("INSERT INTO issues (id, row, seq) VALUES (?, ?, ?)");
123
+ for (const row of snap.issues) ins.run(row.id, JSON.stringify(row), snap.cursor);
124
+ db.run(
125
+ "INSERT INTO sync_state (k, cursor) VALUES ('replica', ?) " +
126
+ "ON CONFLICT(k) DO UPDATE SET cursor = excluded.cursor",
127
+ [snap.cursor],
128
+ );
129
+ });
130
+ tx();
131
+ return snap.cursor;
132
+ },
133
+
134
+ // Own the cursor + the writes; the client just drives the wire.
135
+ getCursor: () =>
136
+ (db.query("SELECT cursor FROM sync_state WHERE k = 'replica'").get() as
137
+ | { cursor: number }
138
+ | undefined)?.cursor ?? null,
139
+
140
+ onChange: (frame) => {
141
+ const tx = db.transaction(() => {
142
+ if (frame.op === "delete") {
143
+ db.run("DELETE FROM issues WHERE id = ?", [frame.entityId]);
144
+ } else {
145
+ db.run(
146
+ "INSERT INTO issues (id, row, seq) VALUES (?, ?, ?) " +
147
+ "ON CONFLICT(id) DO UPDATE SET row = excluded.row, seq = excluded.seq",
148
+ [frame.entityId, JSON.stringify(frame.row), frame.seq],
149
+ );
150
+ }
151
+ db.run("UPDATE sync_state SET cursor = ? WHERE k = 'replica'", [frame.seq]);
152
+ });
153
+ tx();
154
+ },
155
+ });
156
+
157
+ await client.start(); // resolves only when stop() is called — keeps the process alive between deltas
158
+ ```
159
+
160
+ > On Node older than 22, inject a `wsFactory` that wraps the [`ws`](https://www.npmjs.com/package/ws)
161
+ > package: `wsFactory: (url) => new WebSocket(url) as unknown as WebSocketLike`. Node 22+ and Bun
162
+ > expose a global `WebSocket`, so no factory is needed.
163
+
164
+ ## API
165
+
166
+ | Export | What it is |
167
+ | --- | --- |
168
+ | `LiveSyncClient` | The client. `new LiveSyncClient(opts)`, then `start()` / `stop()` / `connectUrl()`. |
169
+ | `LiveSyncClientOptions` | The full options shape (auth, reseed, getCursor, onChange, onStatus, backoff, wsFactory, log). |
170
+ | `AuthStrategy` | `{ kind: "token"; token }` (backend) or `{ kind: "cookie" }` (browser). |
171
+ | `LiveSyncStatus` | `"connecting"` · `"live"` · `"reconnecting"` · `"resyncing"` · `"error"` · `"stopped"`. |
172
+ | `WebSocketLike` / `WebSocketFactory` | The minimal structural WS surface + the injectable factory (for old Node / tests). |
173
+ | `ChangeFrame` · `ResyncFrame` · `SyncFrame` · `ServerFrame` | The wire frames (see below). |
174
+ | `EntityName` · `ChangeOp` · `ENTITY_NAMES` · `CHANGE_OPS` | The canonical table / op contract (types + frozen runtime arrays). |
175
+ | `buildConnectUrl` · `parseFrame` · `toWsOrigin` | The pure helpers the client is built from (exported for diagnostics/tests). |
176
+
177
+ `start()` returns a Promise that resolves **only** when `stop()` is called — on a backend `await` it
178
+ to keep the process alive; in a browser never await it and just call `stop()` on teardown.
179
+
180
+ ## Wire contract
181
+
182
+ The frame shapes (`ChangeFrame`, `ResyncFrame`, `SyncFrame`, `EntityName`, `ChangeOp`) are vendored as
183
+ a self-contained copy of the catalyst-cloud `@catalyst-cloud/types` contract
184
+ ([`src/types.ts`](https://github.com/coalesce-labs/catalyst-cloud-sdk/blob/main/src/types.ts)) — the
185
+ SDK has **no dependency** on that internal package. A contract test pins the literal members so drift
186
+ from the monorepo is caught.
187
+
188
+ ## Contributing
189
+
190
+ The source of truth is `src/`; the published `dist/` is generated by `tsc` and must stay byte-stable
191
+ (the catalyst-cloud monorepo consumes it via a `file:` dependency). Workflow:
192
+
193
+ ```sh
194
+ bun install
195
+ bun run build # tsc -p tsconfig.build.json → dist/
196
+ bun run typecheck # tsc --noEmit over src + test
197
+ bunx vitest run # the test suite (contract + client)
198
+ ```
199
+
200
+ Before opening a PR, run all three and confirm `dist/` has no unexpected diff. CI (GitHub Actions)
201
+ runs install + build + test on every push and PR. Releases are cut by bumping `version` and running
202
+ `npm publish` (the `prepublishOnly` hook rebuilds `dist/` first; the package is published with public
203
+ access via `publishConfig`).
204
+
205
+ ## License
206
+
207
+ MIT © Coalesce Labs — see [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ export { LiveSyncClient, buildConnectUrl, parseFrame, toWsOrigin, type AuthStrategy, type LiveSyncClientOptions, type LiveSyncStatus, type LogLevel, type WebSocketLike, type WebSocketFactory, } from "./live-sync-client.js";
2
+ export { ENTITY_NAMES, CHANGE_OPS, type AccountId, type EntityName, type ChangeOp, type ChangeFrame, type ResyncFrame, type SyncFrame, type ServerFrame, } from "./types.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,cAAc,EACd,eAAe,EACf,UAAU,EACV,UAAU,EACV,KAAK,YAAY,EACjB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,aAAa,EAClB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,YAAY,EACZ,UAAU,EACV,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // @catalyst-cloud/sdk — public entrypoint.
2
+ //
3
+ // The isomorphic (browser + node/bun) read-layer-over-WebSocket client for the catalyst-cloud
4
+ // change-feed (ADR-0008/0009). Open a WebSocket to a tenant's Mirror Durable Object, send a
5
+ // cursor-replay request on every (re)connect, and apply each pushed change into your own store.
6
+ //
7
+ // The class is storage-agnostic and auth-injected: see {@link LiveSyncClient}.
8
+ export { LiveSyncClient, buildConnectUrl, parseFrame, toWsOrigin, } from "./live-sync-client.js";
9
+ export { ENTITY_NAMES, CHANGE_OPS, } from "./types.js";
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2CAA2C;AAC3C,EAAE;AACF,8FAA8F;AAC9F,4FAA4F;AAC5F,gGAAgG;AAChG,EAAE;AACF,+EAA+E;AAE/E,OAAO,EACL,cAAc,EACd,eAAe,EACf,UAAU,EACV,UAAU,GAOX,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,YAAY,EACZ,UAAU,GAQX,MAAM,YAAY,CAAC"}
@@ -0,0 +1,151 @@
1
+ import type { ChangeFrame, ServerFrame } from "./types.js";
2
+ /**
3
+ * The minimal WHATWG-WebSocket surface LiveSyncClient drives. Declared structurally (not via a DOM /
4
+ * Node lib type) so (a) the package needs no `@types/ws` / DOM lib and (b) tests inject a fake. The
5
+ * browser, Bun, and Node (>=22) global `WebSocket` are all structurally assignable to this.
6
+ */
7
+ export interface WebSocketLike {
8
+ send(data: string): void;
9
+ close(code?: number, reason?: string): void;
10
+ onopen: ((ev: unknown) => void) | null;
11
+ onmessage: ((ev: {
12
+ data: unknown;
13
+ }) => void) | null;
14
+ onclose: ((ev: unknown) => void) | null;
15
+ onerror: ((ev: unknown) => void) | null;
16
+ }
17
+ /** Opens a WebSocket to `url`. Defaults to the runtime global `WebSocket`; tests/node inject one. */
18
+ export type WebSocketFactory = (url: string) => WebSocketLike;
19
+ /**
20
+ * How the consumer proves it may open `/connect`.
21
+ *
22
+ * • `token` — a SERVICE bearer (e.g. the host's ADMIN_TOKEN). The WHATWG WebSocket constructor
23
+ * cannot set an Authorization header, so the token rides the URL as `?token=`. The Worker
24
+ * constant-time compares it against the configured secret and STRIPS it before forwarding to the
25
+ * DO. Use this ONLY on a trusted backend (the host daemon) — never the browser.
26
+ * • `cookie` — append NOTHING to the URL. The browser's same-origin session cookie rides the
27
+ * WebSocket upgrade automatically. This makes it impossible to leak a token from the browser path.
28
+ */
29
+ export type AuthStrategy = {
30
+ kind: "token";
31
+ token: string;
32
+ } | {
33
+ kind: "cookie";
34
+ };
35
+ /** Connection lifecycle, surfaced via `onStatus` so a consumer can drive UI. */
36
+ export type LiveSyncStatus = "connecting" | "live" | "reconnecting" | "resyncing" | "error" | "stopped";
37
+ /** Structured log levels (matches the host-sync logger contract). */
38
+ export type LogLevel = "info" | "warn" | "error";
39
+ /** Configuration for a {@link LiveSyncClient}. */
40
+ export interface LiveSyncClientOptions {
41
+ /**
42
+ * The Worker public origin, http(s), INCLUDING any versioned path prefix (e.g.
43
+ * "https://api.example/api/v1"). The scheme is swapped to ws(s) and `connectPath` is appended
44
+ * verbatim (path-preserving). A trailing slash is trimmed.
45
+ */
46
+ baseUrl: string;
47
+ /** The tenant id = the Mirror DO name; sent as `?account=` on the connect URL. */
48
+ accountId: string;
49
+ /**
50
+ * The connect route. Default "/connect"; the Worker dual-serves it at "/api/v1/connect" too, so a
51
+ * path-prefixed `baseUrl` ("…/api/v1") with the default "/connect" resolves to "…/api/v1/connect".
52
+ */
53
+ connectPath?: string;
54
+ /** How to authorize the upgrade. token → ?token= (host); cookie → nothing (browser). */
55
+ auth: AuthStrategy;
56
+ /**
57
+ * Re-seed the consumer's store from a full snapshot and resolve to the FRESH cursor. Called on a
58
+ * `{type:"resync"}` underflow frame and (optionally) before the first connect. The host pulls
59
+ * /snapshot into bun:sqlite; the browser re-runs its OPFS seed(). The client closes the socket
60
+ * before calling this so no live frame interleaves with the seed.
61
+ */
62
+ reseed: () => Promise<number>;
63
+ /**
64
+ * Read the consumer's durable cursor (the last applied change_log.seq), or null/undefined if it has
65
+ * never seeded. Drives the `{type:"sync", after}` catch-up request. Return `null` to send
66
+ * `after: -1` (replay from the start).
67
+ */
68
+ getCursor: () => number | null | undefined;
69
+ /**
70
+ * Land one applied change frame into the consumer's store. The client does NOT persist for you —
71
+ * this is where the host upserts into bun:sqlite and advances its cursor, or the browser writes
72
+ * OPFS. Errors thrown here are caught and logged (one bad frame won't wedge the stream).
73
+ */
74
+ onChange: (frame: ChangeFrame) => void;
75
+ /** Optional: every parsed server frame (change OR resync), before the type-specific handling. */
76
+ onFrame?: (frame: ServerFrame) => void;
77
+ /** Optional: connection lifecycle, for UI ("live"/"reconnecting"/…). */
78
+ onStatus?: (status: LiveSyncStatus) => void;
79
+ /** Base reconnect backoff in ms; doubles each failed attempt up to maxBackoffMs. Default 1000. */
80
+ backoffMs?: number;
81
+ /** Reconnect backoff ceiling in ms. Default 30_000. */
82
+ maxBackoffMs?: number;
83
+ /** Injectable WebSocket factory (tests / a node polyfill). Defaults to `globalThis.WebSocket`. */
84
+ wsFactory?: WebSocketFactory;
85
+ /** Optional structured logger; defaults to console. */
86
+ log?: (level: LogLevel, msg: string, extra?: unknown) => void;
87
+ }
88
+ /** http(s)://host → ws(s)://host, leaving the rest of the origin + path intact (https→wss, http→ws). */
89
+ export declare function toWsOrigin(baseUrl: string): string;
90
+ /**
91
+ * Build the `/connect` URL with `?account=` and, for token auth, `?token=`. For cookie auth NO token
92
+ * is ever appended (the type system + this single construction point make a browser token leak
93
+ * impossible). Token is ordered FIRST so a truncated log line still reveals the account.
94
+ */
95
+ export declare function buildConnectUrl(opts: {
96
+ baseUrl: string;
97
+ connectPath: string;
98
+ accountId: string;
99
+ auth: AuthStrategy;
100
+ }): string;
101
+ export declare class LiveSyncClient {
102
+ private readonly baseUrl;
103
+ private readonly accountId;
104
+ private readonly connectPath;
105
+ private readonly auth;
106
+ private readonly reseed;
107
+ private readonly getCursor;
108
+ private readonly onChange;
109
+ private readonly onFrame?;
110
+ private readonly onStatus?;
111
+ private readonly backoffMs;
112
+ private readonly maxBackoffMs;
113
+ private readonly wsFactory;
114
+ private readonly log;
115
+ private ws;
116
+ private stopped;
117
+ private resyncing;
118
+ private backoff;
119
+ private reconnectTimer;
120
+ private resolveDone;
121
+ constructor(opts: LiveSyncClientOptions);
122
+ /**
123
+ * Start the client: seed first if the consumer has no cursor, then open the live socket and keep it
124
+ * open. Returns a Promise that resolves ONLY when stop() is called (mirrors the host daemon's "runs
125
+ * forever" contract) — the open WebSocket keeps the process alive between deltas. In a browser the
126
+ * returned Promise is simply never awaited; call stop() on teardown.
127
+ */
128
+ start(): Promise<void>;
129
+ /** Stop the client: close the socket, cancel any pending reconnect, resolve start(). Idempotent. */
130
+ stop(): void;
131
+ /** The ws(s):// URL this client opens, for diagnostics/tests. Re-derived from the options. */
132
+ connectUrl(): string;
133
+ private setStatus;
134
+ private openSocket;
135
+ /** Detach handlers BEFORE closing so a programmatic close can't re-enter scheduleReconnect. */
136
+ private closeSocket;
137
+ private scheduleReconnect;
138
+ /** Ask the DO to replay everything after our durable cursor. */
139
+ private sendSync;
140
+ private handleFrame;
141
+ /**
142
+ * Cursor underflow: the deltas we need were evicted from the DO's change_log ring. Close the socket
143
+ * (so no live frame interleaves with the re-seed), re-seed via the injected callback, then reconnect
144
+ * — which re-sends {type:"sync"} from the fresh cursor. `resyncing` guards against a second resync
145
+ * frame and suppresses scheduleReconnect for the duration so we reopen exactly once.
146
+ */
147
+ private handleResync;
148
+ }
149
+ /** Parse a WS frame (string or ArrayBuffer) into a known server frame, or null for anything malformed. */
150
+ export declare function parseFrame(data: unknown): ServerFrame | null;
151
+ //# sourceMappingURL=live-sync-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-sync-client.d.ts","sourceRoot":"","sources":["../src/live-sync-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,293 @@
1
+ // @catalyst-cloud/sdk — LiveSyncClient: the isomorphic read-layer-over-WebSocket client.
2
+ //
3
+ // The push transport for the catalyst-cloud change-feed (ADR-0008/0009). It opens an OUTBOUND
4
+ // WebSocket to a tenant's Mirror Durable Object (`{baseUrl}{connectPath}`) and lets the DO push each
5
+ // change_log delta the instant it lands. On every (re)connect it sends `{type:"sync", after:<cursor>}`
6
+ // so the DO replays — in seq order — everything the consumer missed; a `{type:"resync"}` frame
7
+ // (cursor underflow) triggers a full re-seed via the injected `reseed` callback. It reconnects with
8
+ // capped exponential backoff and never throws out of the event loop.
9
+ //
10
+ // This is the STANDALONE extraction of apps/host-sync/src/live-client.ts. It is deliberately
11
+ // transport-only and storage-agnostic: it does NOT know about bun:sqlite, /snapshot, ADMIN_TOKEN, or
12
+ // any host-only assumption. Those are INJECTED:
13
+ //
14
+ // • auth — {kind:"token", token} (host → ?token=) OR {kind:"cookie"} (browser → nothing; the
15
+ // same-origin cookie rides the upgrade). A cookie-kind client can never leak a token.
16
+ // • reseed — async callback returning the fresh cursor. The host pulls /snapshot into bun:sqlite;
17
+ // the browser re-runs its own OPFS seed(). The class never hardcodes a snapshot path.
18
+ // • onChange — each applied change frame (the consumer lands it into its own store).
19
+ // • onStatus — connection lifecycle ("connecting"/"live"/"reconnecting"/"resyncing"/"error"/
20
+ // "stopped"), so a UI can render "live"/"reconnecting".
21
+ // • wsFactory — injectable WebSocket constructor (tests / a node polyfill). Defaults to the
22
+ // platform global `WebSocket` (browser, Bun, Node >=22) so the shared core has NO
23
+ // node-only import like 'ws'.
24
+ //
25
+ // There is intentionally NO setInterval keepalive: the WHATWG WebSocket ping/pong is handled by the
26
+ // platform and a re-implemented heartbeat would re-pin the hibernating DO (ADR-0009), defeating the
27
+ // whole point of moving off SSE.
28
+ /** Resolve the runtime global WebSocket, or fail with an actionable message. */
29
+ function defaultWsFactory(url) {
30
+ const Ctor = globalThis.WebSocket;
31
+ if (!Ctor) {
32
+ throw new Error("global WebSocket unavailable; pass wsFactory (browser, Bun, or Node >=22 expose one)");
33
+ }
34
+ return new Ctor(url);
35
+ }
36
+ /** http(s)://host → ws(s)://host, leaving the rest of the origin + path intact (https→wss, http→ws). */
37
+ export function toWsOrigin(baseUrl) {
38
+ return baseUrl.replace(/^http/i, "ws");
39
+ }
40
+ /**
41
+ * Build the `/connect` URL with `?account=` and, for token auth, `?token=`. For cookie auth NO token
42
+ * is ever appended (the type system + this single construction point make a browser token leak
43
+ * impossible). Token is ordered FIRST so a truncated log line still reveals the account.
44
+ */
45
+ export function buildConnectUrl(opts) {
46
+ const origin = toWsOrigin(opts.baseUrl.replace(/\/+$/, ""));
47
+ const params = new URLSearchParams();
48
+ if (opts.auth.kind === "token")
49
+ params.set("token", opts.auth.token);
50
+ params.set("account", opts.accountId);
51
+ return `${origin}${opts.connectPath}?${params.toString()}`;
52
+ }
53
+ export class LiveSyncClient {
54
+ baseUrl;
55
+ accountId;
56
+ connectPath;
57
+ auth;
58
+ reseed;
59
+ getCursor;
60
+ onChange;
61
+ onFrame;
62
+ onStatus;
63
+ backoffMs;
64
+ maxBackoffMs;
65
+ wsFactory;
66
+ log;
67
+ ws = null;
68
+ stopped = false;
69
+ resyncing = false;
70
+ backoff;
71
+ reconnectTimer = null;
72
+ resolveDone = null;
73
+ constructor(opts) {
74
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
75
+ this.accountId = opts.accountId;
76
+ this.connectPath = opts.connectPath ?? "/connect";
77
+ this.auth = opts.auth;
78
+ this.reseed = opts.reseed;
79
+ this.getCursor = opts.getCursor;
80
+ this.onChange = opts.onChange;
81
+ this.onFrame = opts.onFrame;
82
+ this.onStatus = opts.onStatus;
83
+ this.backoffMs = opts.backoffMs ?? 1000;
84
+ this.maxBackoffMs = opts.maxBackoffMs ?? 30_000;
85
+ this.wsFactory = opts.wsFactory ?? defaultWsFactory;
86
+ this.log =
87
+ opts.log ??
88
+ ((lvl, msg, extra) => console[lvl === "error" ? "error" : "log"](`[catalyst-sdk:live] ${msg}`, extra ?? ""));
89
+ this.backoff = this.backoffMs;
90
+ }
91
+ /**
92
+ * Start the client: seed first if the consumer has no cursor, then open the live socket and keep it
93
+ * open. Returns a Promise that resolves ONLY when stop() is called (mirrors the host daemon's "runs
94
+ * forever" contract) — the open WebSocket keeps the process alive between deltas. In a browser the
95
+ * returned Promise is simply never awaited; call stop() on teardown.
96
+ */
97
+ async start() {
98
+ this.stopped = false;
99
+ const saved = this.getCursor();
100
+ if (saved == null) {
101
+ this.setStatus("resyncing");
102
+ await this.reseed();
103
+ }
104
+ this.openSocket();
105
+ return new Promise((resolve) => {
106
+ this.resolveDone = resolve;
107
+ });
108
+ }
109
+ /** Stop the client: close the socket, cancel any pending reconnect, resolve start(). Idempotent. */
110
+ stop() {
111
+ this.stopped = true;
112
+ if (this.reconnectTimer != null) {
113
+ clearTimeout(this.reconnectTimer);
114
+ this.reconnectTimer = null;
115
+ }
116
+ this.closeSocket();
117
+ this.setStatus("stopped");
118
+ const done = this.resolveDone;
119
+ this.resolveDone = null;
120
+ done?.();
121
+ }
122
+ /** The ws(s):// URL this client opens, for diagnostics/tests. Re-derived from the options. */
123
+ connectUrl() {
124
+ return buildConnectUrl({
125
+ baseUrl: this.baseUrl,
126
+ connectPath: this.connectPath,
127
+ accountId: this.accountId,
128
+ auth: this.auth,
129
+ });
130
+ }
131
+ setStatus(status) {
132
+ try {
133
+ this.onStatus?.(status);
134
+ }
135
+ catch (err) {
136
+ this.log("warn", "onStatus handler threw", err);
137
+ }
138
+ }
139
+ openSocket() {
140
+ if (this.stopped)
141
+ return;
142
+ this.setStatus("connecting");
143
+ const wsUrl = this.connectUrl();
144
+ let ws;
145
+ try {
146
+ ws = this.wsFactory(wsUrl);
147
+ }
148
+ catch (err) {
149
+ this.log("error", "ws construction failed; scheduling reconnect", err);
150
+ this.setStatus("error");
151
+ this.scheduleReconnect();
152
+ return;
153
+ }
154
+ this.ws = ws;
155
+ ws.onopen = () => {
156
+ this.backoff = this.backoffMs; // a successful open resets the backoff ramp
157
+ this.setStatus("live");
158
+ this.sendSync();
159
+ };
160
+ ws.onmessage = (ev) => {
161
+ void this.handleFrame(ev.data);
162
+ };
163
+ ws.onclose = () => {
164
+ if (this.ws === ws)
165
+ this.ws = null;
166
+ if (!this.stopped && !this.resyncing)
167
+ this.setStatus("reconnecting");
168
+ this.scheduleReconnect();
169
+ };
170
+ ws.onerror = (err) => {
171
+ // Some implementations fire error THEN close; close() here is best-effort and onclose drives the
172
+ // reconnect so we never double-schedule.
173
+ this.log("warn", "ws error", err);
174
+ this.setStatus("error");
175
+ try {
176
+ ws.close();
177
+ }
178
+ catch {
179
+ // already closing/closed
180
+ }
181
+ };
182
+ }
183
+ /** Detach handlers BEFORE closing so a programmatic close can't re-enter scheduleReconnect. */
184
+ closeSocket() {
185
+ const ws = this.ws;
186
+ this.ws = null;
187
+ if (!ws)
188
+ return;
189
+ ws.onopen = null;
190
+ ws.onmessage = null;
191
+ ws.onclose = null;
192
+ ws.onerror = null;
193
+ try {
194
+ ws.close();
195
+ }
196
+ catch {
197
+ // already closed
198
+ }
199
+ }
200
+ scheduleReconnect() {
201
+ if (this.stopped || this.resyncing || this.reconnectTimer != null)
202
+ return;
203
+ const delay = this.backoff;
204
+ this.backoff = Math.min(this.backoff * 2, this.maxBackoffMs);
205
+ this.reconnectTimer = setTimeout(() => {
206
+ this.reconnectTimer = null;
207
+ this.openSocket();
208
+ }, delay);
209
+ }
210
+ /** Ask the DO to replay everything after our durable cursor. */
211
+ sendSync() {
212
+ const after = this.getCursor() ?? -1;
213
+ const frame = { type: "sync", after };
214
+ try {
215
+ this.ws?.send(JSON.stringify(frame));
216
+ }
217
+ catch (err) {
218
+ this.log("error", "sync send failed", err);
219
+ }
220
+ }
221
+ async handleFrame(data) {
222
+ const frame = parseFrame(data);
223
+ if (!frame)
224
+ return;
225
+ try {
226
+ this.onFrame?.(frame);
227
+ }
228
+ catch (err) {
229
+ this.log("warn", "onFrame handler threw", err);
230
+ }
231
+ if (frame.type === "resync") {
232
+ await this.handleResync();
233
+ return;
234
+ }
235
+ try {
236
+ this.onChange(frame);
237
+ }
238
+ catch (err) {
239
+ this.log("error", `onChange failed for ${frame.entity} seq=${frame.seq}`, err);
240
+ }
241
+ }
242
+ /**
243
+ * Cursor underflow: the deltas we need were evicted from the DO's change_log ring. Close the socket
244
+ * (so no live frame interleaves with the re-seed), re-seed via the injected callback, then reconnect
245
+ * — which re-sends {type:"sync"} from the fresh cursor. `resyncing` guards against a second resync
246
+ * frame and suppresses scheduleReconnect for the duration so we reopen exactly once.
247
+ */
248
+ async handleResync() {
249
+ if (this.resyncing)
250
+ return;
251
+ this.resyncing = true;
252
+ this.setStatus("resyncing");
253
+ this.closeSocket();
254
+ try {
255
+ const cursor = await this.reseed();
256
+ this.log("info", `resynced, cursor=${cursor}`);
257
+ }
258
+ catch (err) {
259
+ this.log("error", "resync reseed failed; will retry on reconnect", err);
260
+ }
261
+ finally {
262
+ this.resyncing = false;
263
+ }
264
+ if (!this.stopped)
265
+ this.openSocket();
266
+ }
267
+ }
268
+ /** Parse a WS frame (string or ArrayBuffer) into a known server frame, or null for anything malformed. */
269
+ export function parseFrame(data) {
270
+ const text = typeof data === "string"
271
+ ? data
272
+ : data instanceof ArrayBuffer
273
+ ? new TextDecoder().decode(data)
274
+ : null;
275
+ if (text == null)
276
+ return null;
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(text);
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ if (typeof parsed !== "object" || parsed === null)
285
+ return null;
286
+ const type = parsed.type;
287
+ if (type === "resync")
288
+ return parsed;
289
+ if (type === "change")
290
+ return parsed;
291
+ return null;
292
+ }
293
+ //# sourceMappingURL=live-sync-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"live-sync-client.js","sourceRoot":"","sources":["../src/live-sync-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,57 @@
1
+ /** The tenant id. Also the Mirror DO name. `"tenant-0"` is Ryan's own workspace (Phase 0). */
2
+ export type AccountId = string;
3
+ /**
4
+ * Canonical mirror table names — the `entity` field in change_log + the change-feed wire.
5
+ *
6
+ * Mirrors @catalyst-cloud/types `EntityName` exactly. The runtime list `ENTITY_NAMES` below is the
7
+ * same set as an array so the contract test can assert membership at runtime (a bare `type` alias
8
+ * erases to nothing and can't be checked).
9
+ */
10
+ export type EntityName = "issues" | "labels" | "users" | "issue_labels" | "relations" | "issue_history" | "projects" | "cycles" | "initiatives" | "project_initiatives" | "comments" | "pull_requests" | "check_runs" | "commit_statuses" | "reviews";
11
+ /**
12
+ * The `EntityName` union as a runtime array, in the SAME ORDER as the type above. Frozen so consumers
13
+ * (and the contract test) can iterate the canonical table set. Kept in lockstep with `EntityName` —
14
+ * the contract test asserts the two agree.
15
+ */
16
+ export declare const ENTITY_NAMES: readonly ["issues", "labels", "users", "issue_labels", "relations", "issue_history", "projects", "cycles", "initiatives", "project_initiatives", "comments", "pull_requests", "check_runs", "commit_statuses", "reviews"];
17
+ /** change_log.op — the change-feed wire contract. */
18
+ export type ChangeOp = "upsert" | "delete";
19
+ /** The `ChangeOp` union as a runtime array (same lockstep contract as `ENTITY_NAMES`). */
20
+ export declare const CHANGE_OPS: readonly ["upsert", "delete"];
21
+ /**
22
+ * A live change frame off the `/connect` WebSocket — the exact shape `apps/mirror/src/do/ws.ts`
23
+ * broadcasts and replays. One row of the change_log, serialized.
24
+ */
25
+ export interface ChangeFrame {
26
+ type: "change";
27
+ accountId: AccountId;
28
+ /** The change_log seq — the monotonic cursor the replica advances to. */
29
+ seq: number;
30
+ entity: EntityName;
31
+ /** The change_log.entity_id — the PK (composite PKs joined with ':'). */
32
+ entityId: string;
33
+ op: ChangeOp;
34
+ /** The full normalized row for "upsert"; absent / partial for "delete". */
35
+ row?: Record<string, unknown>;
36
+ }
37
+ /**
38
+ * The underflow control frame: the consumer's cursor predates the DO's retained change_log ring →
39
+ * the consumer must re-seed from a full /snapshot.
40
+ */
41
+ export interface ResyncFrame {
42
+ type: "resync";
43
+ accountId?: AccountId;
44
+ }
45
+ /**
46
+ * The catch-up request the consumer sends on every (re)connect: "replay everything after this
47
+ * cursor, in seq order". The DO answers with the missed `ChangeFrame`s, or a `ResyncFrame` if `after`
48
+ * predates the retained ring (cursor underflow).
49
+ */
50
+ export interface SyncFrame {
51
+ type: "sync";
52
+ /** The durable cursor: the last change_log.seq the consumer has applied (-1 if none). */
53
+ after: number;
54
+ }
55
+ /** Any frame the DO can push to a consumer over `/connect`. */
56
+ export type ServerFrame = ChangeFrame | ResyncFrame;
57
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAcA,8FAA8F;AAC9F,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAClB,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,cAAc,GACd,WAAW,GACX,eAAe,GACf,UAAU,GACV,QAAQ,GACR,aAAa,GACb,qBAAqB,GACrB,UAAU,GACV,eAAe,GACf,YAAY,GACZ,iBAAiB,GACjB,SAAS,CAAC;AAEd;;;;GAIG;AACH,eAAO,MAAM,YAAY,2NAgBiB,CAAC;AAE3C,qDAAqD;AACrD,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE3C,0FAA0F;AAC1F,eAAO,MAAM,UAAU,+BAA8D,CAAC;AAEtF;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,EAAE,SAAS,CAAC;IACrB,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,UAAU,CAAC;IACnB,yEAAyE;IACzE,QAAQ,EAAE,MAAM,CAAC;IACjB,EAAE,EAAE,QAAQ,CAAC;IACb,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,QAAQ,CAAC;IACf,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAC;CACf;AAED,+DAA+D;AAC/D,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,38 @@
1
+ // catalyst-cloud-sdk wire types — the change-feed contract, SELF-CONTAINED.
2
+ //
3
+ // This file is a deliberate, byte-for-byte copy of the portable wire contract from the
4
+ // catalyst-cloud monorepo's @catalyst-cloud/types package (packages/types/src/index.ts): the
5
+ // `EntityName` table-name union, the `ChangeOp` op union, and the change/sync/resync frame shapes
6
+ // the Mirror Durable Object broadcasts over `/connect`. The SDK is published standalone, so it must
7
+ // NOT depend on the internal @catalyst-cloud/types workspace package — instead it owns its own copy
8
+ // of the contract, and a contract test (test/contract.test.ts) pins the literal members so any drift
9
+ // from the monorepo is caught.
10
+ //
11
+ // Like @catalyst-cloud/types, this module is Cloudflare-Workers-free and DOM-free: it is pure type
12
+ // declarations + plain JS runtime constants, so it imports cleanly into a browser bundle, a node/bun
13
+ // app, and a Worker alike.
14
+ /**
15
+ * The `EntityName` union as a runtime array, in the SAME ORDER as the type above. Frozen so consumers
16
+ * (and the contract test) can iterate the canonical table set. Kept in lockstep with `EntityName` —
17
+ * the contract test asserts the two agree.
18
+ */
19
+ export const ENTITY_NAMES = [
20
+ "issues",
21
+ "labels",
22
+ "users",
23
+ "issue_labels",
24
+ "relations",
25
+ "issue_history",
26
+ "projects",
27
+ "cycles",
28
+ "initiatives",
29
+ "project_initiatives",
30
+ "comments",
31
+ "pull_requests",
32
+ "check_runs",
33
+ "commit_statuses",
34
+ "reviews",
35
+ ];
36
+ /** The `ChangeOp` union as a runtime array (same lockstep contract as `ENTITY_NAMES`). */
37
+ export const CHANGE_OPS = ["upsert", "delete"];
38
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,uFAAuF;AACvF,6FAA6F;AAC7F,kGAAkG;AAClG,oGAAoG;AACpG,oGAAoG;AACpG,qGAAqG;AACrG,+BAA+B;AAC/B,EAAE;AACF,mGAAmG;AACnG,qGAAqG;AACrG,2BAA2B;AA6B3B;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,QAAQ;IACR,QAAQ;IACR,OAAO;IACP,cAAc;IACd,WAAW;IACX,eAAe;IACf,UAAU;IACV,QAAQ;IACR,aAAa;IACb,qBAAqB;IACrB,UAAU;IACV,eAAe;IACf,YAAY;IACZ,iBAAiB;IACjB,SAAS;CAC+B,CAAC;AAK3C,0FAA0F;AAC1F,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAwC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@catalyst-cloud/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Isomorphic (browser + node/bun) read-layer-over-WebSocket client for the catalyst-cloud change-feed (ADR-0008/0009).",
5
+ "license": "MIT",
6
+ "author": "Coalesce Labs",
7
+ "homepage": "https://github.com/coalesce-labs/catalyst-cloud-sdk#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/coalesce-labs/catalyst-cloud-sdk.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/coalesce-labs/catalyst-cloud-sdk/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.build.json",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run",
40
+ "prepublishOnly": "npm run build"
41
+ },
42
+ "keywords": [
43
+ "catalyst",
44
+ "websocket",
45
+ "change-feed",
46
+ "live-sync",
47
+ "isomorphic",
48
+ "cloudflare",
49
+ "durable-objects"
50
+ ],
51
+ "devDependencies": {
52
+ "typescript": "^5.6.3",
53
+ "vitest": "^2.1.8"
54
+ }
55
+ }