@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 +21 -0
- package/README.md +207 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live-sync-client.d.ts +151 -0
- package/dist/live-sync-client.d.ts.map +1 -0
- package/dist/live-sync-client.js +293 -0
- package/dist/live-sync-client.js.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +38 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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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"}
|
package/dist/types.d.ts
ADDED
|
@@ -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
|
+
}
|