@catalyst-cloud/sdk 0.6.0 → 0.8.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.
Files changed (74) hide show
  1. package/README.md +92 -0
  2. package/dist/browser.d.ts +9 -0
  3. package/dist/browser.d.ts.map +1 -0
  4. package/dist/browser.js +24 -0
  5. package/dist/browser.js.map +1 -0
  6. package/dist/live-sync-client.d.ts +203 -13
  7. package/dist/live-sync-client.d.ts.map +1 -1
  8. package/dist/live-sync-client.js +605 -50
  9. package/dist/live-sync-client.js.map +1 -1
  10. package/dist/replica/browser/apply.d.ts +29 -0
  11. package/dist/replica/browser/apply.d.ts.map +1 -0
  12. package/dist/replica/browser/apply.js +59 -0
  13. package/dist/replica/browser/apply.js.map +1 -0
  14. package/dist/replica/browser/browser-lock.d.ts +25 -0
  15. package/dist/replica/browser/browser-lock.d.ts.map +1 -0
  16. package/dist/replica/browser/browser-lock.js +91 -0
  17. package/dist/replica/browser/browser-lock.js.map +1 -0
  18. package/dist/replica/browser/browser-replica.d.ts +237 -0
  19. package/dist/replica/browser/browser-replica.d.ts.map +1 -0
  20. package/dist/replica/browser/browser-replica.js +952 -0
  21. package/dist/replica/browser/browser-replica.js.map +1 -0
  22. package/dist/replica/browser/db.worker.d.ts +2 -0
  23. package/dist/replica/browser/db.worker.d.ts.map +1 -0
  24. package/dist/replica/browser/db.worker.js +40 -0
  25. package/dist/replica/browser/db.worker.js.map +1 -0
  26. package/dist/replica/browser/delta-queue.d.ts +187 -0
  27. package/dist/replica/browser/delta-queue.d.ts.map +1 -0
  28. package/dist/replica/browser/delta-queue.js +328 -0
  29. package/dist/replica/browser/delta-queue.js.map +1 -0
  30. package/dist/replica/browser/ports.d.ts +39 -0
  31. package/dist/replica/browser/ports.d.ts.map +1 -0
  32. package/dist/replica/browser/ports.js +144 -0
  33. package/dist/replica/browser/ports.js.map +1 -0
  34. package/dist/replica/browser/protocol.d.ts +129 -0
  35. package/dist/replica/browser/protocol.d.ts.map +1 -0
  36. package/dist/replica/browser/protocol.js +12 -0
  37. package/dist/replica/browser/protocol.js.map +1 -0
  38. package/dist/replica/browser/seed-read-gate.d.ts +13 -0
  39. package/dist/replica/browser/seed-read-gate.d.ts.map +1 -0
  40. package/dist/replica/browser/seed-read-gate.js +36 -0
  41. package/dist/replica/browser/seed-read-gate.js.map +1 -0
  42. package/dist/replica/browser/seed-session.d.ts +23 -0
  43. package/dist/replica/browser/seed-session.d.ts.map +1 -0
  44. package/dist/replica/browser/seed-session.js +57 -0
  45. package/dist/replica/browser/seed-session.js.map +1 -0
  46. package/dist/replica/browser/snapshot-stream.d.ts +24 -0
  47. package/dist/replica/browser/snapshot-stream.d.ts.map +1 -0
  48. package/dist/replica/browser/snapshot-stream.js +113 -0
  49. package/dist/replica/browser/snapshot-stream.js.map +1 -0
  50. package/dist/replica/browser/sqlite-db.d.ts +9 -0
  51. package/dist/replica/browser/sqlite-db.d.ts.map +1 -0
  52. package/dist/replica/browser/sqlite-db.js +28 -0
  53. package/dist/replica/browser/sqlite-db.js.map +1 -0
  54. package/dist/replica/browser/support.d.ts +2 -0
  55. package/dist/replica/browser/support.d.ts.map +1 -0
  56. package/dist/replica/browser/support.js +30 -0
  57. package/dist/replica/browser/support.js.map +1 -0
  58. package/dist/replica/browser/validate.d.ts +17 -0
  59. package/dist/replica/browser/validate.d.ts.map +1 -0
  60. package/dist/replica/browser/validate.js +47 -0
  61. package/dist/replica/browser/validate.js.map +1 -0
  62. package/dist/replica/browser/worker-core.d.ts +10 -0
  63. package/dist/replica/browser/worker-core.d.ts.map +1 -0
  64. package/dist/replica/browser/worker-core.js +181 -0
  65. package/dist/replica/browser/worker-core.js.map +1 -0
  66. package/dist/replica/catalyst-replica.d.ts +34 -10
  67. package/dist/replica/catalyst-replica.d.ts.map +1 -1
  68. package/dist/replica/catalyst-replica.js +195 -83
  69. package/dist/replica/catalyst-replica.js.map +1 -1
  70. package/dist/replica/migration-shape.d.ts +11 -0
  71. package/dist/replica/migration-shape.d.ts.map +1 -0
  72. package/dist/replica/migration-shape.js +25 -0
  73. package/dist/replica/migration-shape.js.map +1 -0
  74. package/package.json +16 -3
@@ -0,0 +1,39 @@
1
+ import type { Database } from "@sqlite.org/sqlite-wasm";
2
+ import type { SqlExecutor, SqlValue } from "@catalyst-cloud/read-model";
3
+ /**
4
+ * The write port the delta-apply path (apply.ts) drives. `run` executes a mutation with positional `?`
5
+ * bindings and reports whether it changed a row (mirrors bun:sqlite's `db.query(sql).run(...).changes`);
6
+ * `get` runs a single-row read; `transaction` wraps a unit of work in BEGIN/COMMIT (ROLLBACK on throw)
7
+ * so a snapshot seed lands atomically — exactly the guarantee host-sync gets from bun's `db.transaction`.
8
+ */
9
+ export interface ReplicaDb {
10
+ /** Execute a mutation; returns the number of rows changed (sqlite3_changes()). */
11
+ run(sql: string, ...bindings: SqlValue[]): number;
12
+ /** Run a single-row query; returns the first row as an object, or undefined. */
13
+ get(sql: string, ...bindings: SqlValue[]): Record<string, SqlValue> | undefined;
14
+ /** Wrap `fn` in a transaction (BEGIN/COMMIT; ROLLBACK + rethrow on error). */
15
+ transaction<T>(fn: () => T): T;
16
+ }
17
+ /** Coerce a wire JSON value to a sqlite-wasm-bindable scalar. Booleans → 0/1, nested objects → JSON
18
+ * text, undefined/null → null. Mirrors the node engine's `toBindable` so both replicas store the
19
+ * same bytes for the same wire row. */
20
+ export declare function toBindable(v: unknown): SqlValue;
21
+ /** The opened replica: the raw OO1 handle plus the three typed ports the worker drives. */
22
+ export interface OpenedReplica {
23
+ /** read-model read seam — buildIssuesView/buildIssueDetail/buildPullsView run over this UNCHANGED. */
24
+ readonly read: SqlExecutor;
25
+ /** delta-apply write seam — apply.ts (the host-sync twin) routes through this. */
26
+ readonly write: ReplicaDb;
27
+ /** the underlying OO1 db handle (for close / capacity introspection). */
28
+ readonly db: Database;
29
+ /** release the handle (under OPFS SAHPool this frees the SyncAccessHandles for another tab). */
30
+ close(): void;
31
+ }
32
+ /**
33
+ * Build the three ports over an ALREADY-OPEN OO1 database, bring its schema up to date by applying the
34
+ * SHARED migration bundle (the same `MIRROR_MIGRATIONS` the DO + daemon use), and create the host-only
35
+ * `sync_meta` cursor table. Pure over the handle — the production opener (sqlite-db.ts, OPFS SAHPool)
36
+ * and the test opener (`:memory:`) both route through here, so the ports can never drift between them.
37
+ */
38
+ export declare function buildOpenedReplica(db: Database): OpenedReplica;
39
+ //# sourceMappingURL=ports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/ports.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACxD,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAC;AAIxE;;;;;GAKG;AACH,MAAM,WAAW,SAAS;IACxB,kFAAkF;IAClF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC;IAClD,gFAAgF;IAChF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,QAAQ,EAAE,QAAQ,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,SAAS,CAAC;IAChF,8EAA8E;IAC9E,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;CAChC;AAED;;wCAEwC;AACxC,wBAAgB,UAAU,CAAC,CAAC,EAAE,OAAO,GAAG,QAAQ,CAa/C;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,sGAAsG;IACtG,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,kFAAkF;IAClF,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;IACtB,gGAAgG;IAChG,KAAK,IAAI,IAAI,CAAC;CACf;AAuBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,QAAQ,GAAG,aAAa,CAsF9D"}
@@ -0,0 +1,144 @@
1
+ // replica/browser/ports.ts — the three typed ports the browser replica drives over a sqlite-wasm OO1
2
+ // `Database`, split from the OPFS opener (sqlite-db.ts) so the SAME port-building code serves BOTH the
3
+ // production OPFS SAHPool DB and a plain `:memory:` DB in node-side tests (sqlite-wasm runs fine under
4
+ // node — no OPFS required to exercise seed/apply/query logic).
5
+ //
6
+ // The whole point of ADR-0002 is "one query, four runtimes": the read-model's `SqlExecutor` and the
7
+ // schema package's `MigrationDb` are structural interfaces the DO (Workers SqlStorage), the host-sync
8
+ // daemon (bun:sqlite), and the browser all satisfy. This file is the browser adapter — the exact
9
+ // counterpart to the SDK node engine (src/replica/engine.ts), plus a small write port for the
10
+ // delta-apply path.
11
+ //
12
+ // • SqlExecutor (read) — read-model: exec(q, ...binds).toArray() → object rows.
13
+ // • MigrationDb (migrate)— schema: exec(sql) + query(sql) (no bindings).
14
+ // • ReplicaDb (write) — run/get with positional bindings, for the apply path (apply.ts).
15
+ import { applyMigrations, MIRROR_MIGRATIONS } from "@catalyst-cloud/schema";
16
+ import { migrationsChangeRowShape } from "../migration-shape.js";
17
+ /** Coerce a wire JSON value to a sqlite-wasm-bindable scalar. Booleans → 0/1, nested objects → JSON
18
+ * text, undefined/null → null. Mirrors the node engine's `toBindable` so both replicas store the
19
+ * same bytes for the same wire row. */
20
+ export function toBindable(v) {
21
+ if (v === null || v === undefined)
22
+ return null;
23
+ if (typeof v === "boolean")
24
+ return v ? 1 : 0;
25
+ if (typeof v === "string" || typeof v === "number")
26
+ return v;
27
+ // bigint is outside the read-model SqlValue union (no column needs it) — coerce to number.
28
+ if (typeof v === "bigint")
29
+ return Number(v);
30
+ if (v instanceof ArrayBuffer)
31
+ return v;
32
+ if (v instanceof Uint8Array) {
33
+ // Copy to a standalone ArrayBuffer (the view may be a slice of a larger buffer).
34
+ return v.slice().buffer;
35
+ }
36
+ // Nested object/array (shouldn't occur — DO rows are flat scalars) → JSON text rather than throw.
37
+ return JSON.stringify(v);
38
+ }
39
+ /** A sqlite-wasm bindable array, narrowed to what the read-model/apply paths actually bind. */
40
+ function bindArgs(bindings) {
41
+ return bindings.length > 0 ? bindings : undefined;
42
+ }
43
+ /**
44
+ * Read the persisted cursor straight off the handle, before the write port exists.
45
+ *
46
+ * The row-shape check below runs inside `buildOpenedReplica`, so apply.ts's `getCursor(db.write)` is not
47
+ * available yet — and it must stay that way: apply.ts imports the shared replicate helpers, and pulling
48
+ * that graph in here just to read one row would invert the layering this file exists to keep flat.
49
+ */
50
+ function readCursorRow(db) {
51
+ const rows = db.exec("SELECT value FROM sync_meta WHERE key = 'cursor'", {
52
+ rowMode: "object",
53
+ returnValue: "resultRows",
54
+ });
55
+ const v = rows[0]?.value;
56
+ return typeof v === "string" ? v : null;
57
+ }
58
+ /**
59
+ * Build the three ports over an ALREADY-OPEN OO1 database, bring its schema up to date by applying the
60
+ * SHARED migration bundle (the same `MIRROR_MIGRATIONS` the DO + daemon use), and create the host-only
61
+ * `sync_meta` cursor table. Pure over the handle — the production opener (sqlite-db.ts, OPFS SAHPool)
62
+ * and the test opener (`:memory:`) both route through here, so the ports can never drift between them.
63
+ */
64
+ export function buildOpenedReplica(db) {
65
+ // read-model SqlExecutor — the load-bearing ADR-0002 proof in the browser. exec(query, ...binds)
66
+ // returns object rows keyed by SELECTed column alias, which buildIssuesView/etc consume directly.
67
+ const read = {
68
+ exec: (query, ...bindings) => ({
69
+ toArray: () => db.exec(query, {
70
+ bind: bindArgs(bindings),
71
+ rowMode: "object",
72
+ returnValue: "resultRows",
73
+ }),
74
+ }),
75
+ };
76
+ // Write port — positional-binding mutations + single-row reads + a transaction wrapper.
77
+ const write = {
78
+ run: (sql, ...bindings) => {
79
+ db.exec(sql, { bind: bindArgs(bindings) });
80
+ // sqlite3_changes() via the OO1 helper — how many rows the last statement touched.
81
+ return db.changes();
82
+ },
83
+ get: (sql, ...bindings) => {
84
+ const rows = db.exec(sql, {
85
+ bind: bindArgs(bindings),
86
+ rowMode: "object",
87
+ returnValue: "resultRows",
88
+ });
89
+ return rows[0];
90
+ },
91
+ transaction: (fn) => {
92
+ db.exec("BEGIN");
93
+ try {
94
+ const out = fn();
95
+ db.exec("COMMIT");
96
+ return out;
97
+ }
98
+ catch (err) {
99
+ try {
100
+ db.exec("ROLLBACK");
101
+ }
102
+ catch {
103
+ // ROLLBACK can throw if the txn already aborted — swallow so the original error surfaces.
104
+ }
105
+ throw err;
106
+ }
107
+ },
108
+ };
109
+ // Apply the cross-surface mirror migrations via the ~3-line MigrationDb adapter (node-engine twin).
110
+ // exec runs ONE statement; query returns object rows. No bindings — the runner inlines its only literal.
111
+ const migrationAdapter = {
112
+ exec: (sql) => {
113
+ db.exec(sql);
114
+ },
115
+ query: (sql) => db.exec(sql, { rowMode: "object", returnValue: "resultRows" }),
116
+ };
117
+ const { appliedTags } = applyMigrations(migrationAdapter, MIRROR_MIGRATIONS);
118
+ // Host-only bookkeeping table (NOT in the DO mirror schema, so not in the bundle) — the cursor.
119
+ // Identical to the node engine's SYNC_META_DDL.
120
+ db.exec("CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)");
121
+ // CTC-127's browser twin (CTC-114 review, KtI). A migration that adds a column or a table leaves a
122
+ // WARM replica's existing rows holding NULL for it forever — deltas only carry CHANGED rows, so
123
+ // nothing ever backfills them. Dropping the cursor makes the client's warm-start check read cold and
124
+ // take the /snapshot path exactly once. The node replica has done this since CTC-127; without it the
125
+ // browser bites on the FIRST row-shape migration after 0.8.0.
126
+ //
127
+ // On the `cursor != null` guard: it is DEFENCE IN DEPTH, not the thing that makes this correct.
128
+ // What makes it correct is that `applyMigrations` reports only the tags it applied THIS call —
129
+ // verified: 9 on a cold database, 0 on a re-open — so a fully-migrated warm tab evaluates the
130
+ // predicate over an EMPTY list and never reaches the delete. (An earlier reading of this had the
131
+ // guard preventing a re-download on every warm reload; that is not what happens, because the
132
+ // predicate is already false there. It is kept because it costs one indexed lookup and it is what
133
+ // holds the line if the runner's contract ever changes to report all known tags.)
134
+ if (readCursorRow(db) != null && migrationsChangeRowShape(appliedTags)) {
135
+ db.exec("DELETE FROM sync_meta WHERE key = 'cursor'");
136
+ }
137
+ return {
138
+ read,
139
+ write,
140
+ db,
141
+ close: () => db.close(),
142
+ };
143
+ }
144
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.js","sourceRoot":"","sources":["../../../src/replica/browser/ports.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,129 @@
1
+ import type { IssueView, IssueDetailView, PullView } from "@catalyst-cloud/read-model";
2
+ /** A change-feed record as it rides the wire — the shape both /snapshot data lines and /changes rows
3
+ * decode to (accountId/seq are bookkeeping the worker tracks separately). Mirrors host-sync's
4
+ * ChangeRecord (apps/host-sync/src/apply.ts) field-for-field so the apply path is a true twin. */
5
+ export interface WireChange {
6
+ entity: string;
7
+ op: "upsert" | "delete";
8
+ row: Record<string, unknown>;
9
+ /** change_log.entity_id — the PK (composite PKs joined with ':'); required to apply a delete. */
10
+ entityId?: string;
11
+ }
12
+ /** Open the OPFS SAHPool DB and bring its schema up to date (applyMigrations + sync_meta). Idempotent. */
13
+ export interface OpenRequest {
14
+ type: "open";
15
+ /** Absolute OPFS db path (SAHPool requires a leading slash). */
16
+ dbPath: string;
17
+ /** OPFS directory the SAHPool VFS manages (one per app to avoid cross-engine collisions). */
18
+ directory: string;
19
+ /**
20
+ * Opaque TENANT FENCE for the persisted database (CTC-114 review).
21
+ *
22
+ * `dbPath`/`directory` default to constants, so every tenant on an origin opens the SAME OPFS
23
+ * database. The warm-start path skips `/snapshot` whenever the persisted cursor is non-null — so
24
+ * after the cookie user changes, or the app starts a replica for a different `accountId`, the
25
+ * previous tenant's rows stayed queryable and no delta could ever remove them (deltas only carry
26
+ * changes, never "forget everything you knew").
27
+ *
28
+ * The worker compares this against the value stored in `sync_meta` and, on a mismatch, truncates
29
+ * the replica and clears the cursor so the client cold-starts into a fresh snapshot. Comparing at
30
+ * OPEN is what makes it a fence: it happens before any read can be served.
31
+ */
32
+ identity: string;
33
+ }
34
+ /**
35
+ * Batched streaming seed (CTC-132) — replaces the one-shot `SeedRequest`. The snapshot rides in across
36
+ * MANY messages so no layer buffers the whole ~74 MB body: `seedBegin` opens one transaction and
37
+ * truncates, each `seedBatch` applies a bounded page, `seedCommit` sets the cursor and COMMITs, and
38
+ * `seedAbort` rolls back on error. The worker holds one open `SeedSession` for the duration.
39
+ */
40
+ export interface SeedBeginRequest {
41
+ type: "seedBegin";
42
+ }
43
+ export interface SeedBatchRequest {
44
+ type: "seedBatch";
45
+ rows: WireChange[];
46
+ }
47
+ export interface SeedCommitRequest {
48
+ type: "seedCommit";
49
+ cursor: number;
50
+ }
51
+ export interface SeedAbortRequest {
52
+ type: "seedAbort";
53
+ }
54
+ /** Apply a batch of live deltas (each a /changes row) and advance the cursor to the max seq seen. */
55
+ export interface ApplyChangesRequest {
56
+ type: "applyChanges";
57
+ changes: Array<WireChange & {
58
+ seq: number;
59
+ }>;
60
+ }
61
+ /** Read the issues list view locally (buildIssuesView over the OPFS replica). */
62
+ export interface QueryIssuesRequest {
63
+ type: "queryIssues";
64
+ limit?: number;
65
+ offset?: number;
66
+ }
67
+ /** Read one issue's detail view locally (buildIssueDetail). */
68
+ export interface QueryIssueDetailRequest {
69
+ type: "queryIssueDetail";
70
+ identifier: string;
71
+ }
72
+ /** Read the pull-requests view locally (buildPullsView). */
73
+ export interface QueryPullsRequest {
74
+ type: "queryPulls";
75
+ limit?: number;
76
+ offset?: number;
77
+ }
78
+ /** Read the persisted change-feed cursor (or null if no snapshot has ever completed). */
79
+ export interface GetCursorRequest {
80
+ type: "getCursor";
81
+ }
82
+ /**
83
+ * Close the DB cleanly BEFORE the Worker is terminated (CTC-114). `worker.terminate()` alone drops the
84
+ * OPFS SyncAccessHandles by process kill; an explicit close releases them cooperatively so the next
85
+ * boot (same tab reload, or another tab winning the Web Lock) doesn't race a not-yet-released pool.
86
+ */
87
+ export interface CloseRequest {
88
+ type: "close";
89
+ }
90
+ export type ReplicaRequest = OpenRequest | SeedBeginRequest | SeedBatchRequest | SeedCommitRequest | SeedAbortRequest | ApplyChangesRequest | QueryIssuesRequest | QueryIssueDetailRequest | QueryPullsRequest | GetCursorRequest | CloseRequest;
91
+ /** Every request carries an `id`; the worker echoes it so the client can settle the matching promise. */
92
+ export interface Envelope<T extends ReplicaRequest = ReplicaRequest> {
93
+ id: number;
94
+ request: T;
95
+ }
96
+ /** A successful reply, discriminated on the originating request `type`. `result` shape follows from it. */
97
+ export interface ReplicaOk {
98
+ id: number;
99
+ ok: true;
100
+ /** The reply payload — view rows, a cursor, an applied count, or null/void for open/seed. */
101
+ result: unknown;
102
+ }
103
+ /** A failed reply — the worker never throws across the boundary; it serializes the message. */
104
+ export interface ReplicaErr {
105
+ id: number;
106
+ ok: false;
107
+ error: string;
108
+ }
109
+ export type ReplicaResponse = ReplicaOk | ReplicaErr;
110
+ /** Result of an applyChanges call — how many rows actually landed and the cursor afterwards. */
111
+ export interface ApplyChangesResult {
112
+ applied: number;
113
+ cursor: number;
114
+ }
115
+ /** Strongly-typed result lookup so the client can cast a reply by the request type it sent. */
116
+ export interface ResultMap {
117
+ open: void;
118
+ seedBegin: void;
119
+ seedBatch: void;
120
+ seedCommit: number;
121
+ seedAbort: void;
122
+ applyChanges: ApplyChangesResult;
123
+ queryIssues: IssueView[];
124
+ queryIssueDetail: IssueDetailView | null;
125
+ queryPulls: PullView[];
126
+ getCursor: number | null;
127
+ close: void;
128
+ }
129
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/protocol.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ // replica/protocol.ts — the typed RPC contract between the main thread and the OPFS replica Worker
2
+ // (CTC-51, ADR-0002 browser twin of apps/host-sync). The SAHPool VFS only works inside a Web Worker
3
+ // (OPFS SyncAccessHandles are Worker-only — see EXTERNAL recipe / sqlite.org persistence docs), so the
4
+ // whole replica DB lives in db.worker.ts and the main-thread client (replica-client.ts) talks to it
5
+ // over postMessage. This module is the SINGLE source of truth for those message shapes — imported by
6
+ // both sides so a request and its response can never drift.
7
+ //
8
+ // The wire records (snapshot lines / change lines) are the SAME change-feed contract host-sync's
9
+ // sync-client.ts consumes; the read results are the SAME read-model view types the DO + daemon return.
10
+ // Nothing here imports sqlite-wasm or bun:sqlite, so it stays runtime-agnostic and tree-shakes cleanly.
11
+ export {};
12
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../../src/replica/browser/protocol.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,oGAAoG;AACpG,uGAAuG;AACvG,oGAAoG;AACpG,qGAAqG;AACrG,4DAA4D;AAC5D,EAAE;AACF,iGAAiG;AACjG,uGAAuG;AACvG,wGAAwG"}
@@ -0,0 +1,13 @@
1
+ export declare class SeedReadGate {
2
+ /** Resolvers for reads deferred during the current seed; drained on settle(). */
3
+ private waiters;
4
+ /**
5
+ * Return a promise a read should await before touching the DB. When no seed is open the caller passes
6
+ * `seeding=false` and gets an already-resolved promise (zero overhead on the hot read path); while a
7
+ * seed is open it resolves only once settle() runs.
8
+ */
9
+ whenReadable(seeding: boolean): Promise<void>;
10
+ /** Release every read deferred during the just-finished seed — call on BOTH commit and abort. */
11
+ settle(): void;
12
+ }
13
+ //# sourceMappingURL=seed-read-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-read-gate.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/seed-read-gate.ts"],"names":[],"mappings":"AAeA,qBAAa,YAAY;IACvB,iFAAiF;IACjF,OAAO,CAAC,OAAO,CAAyB;IAExC;;;;OAIG;IACH,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7C,iGAAiG;IACjG,MAAM,IAAI,IAAI;CAKf"}
@@ -0,0 +1,36 @@
1
+ // replica/seed-read-gate.ts — the Worker's mid-seed READ gate (CTC-132 review finding).
2
+ //
3
+ // The batched streaming seed (seedBegin → seedBatch* → seedCommit/seedAbort) holds ONE SQLite
4
+ // transaction open across many Worker messages, and the replica's read + write adapters share the same
5
+ // sqlite-wasm handle. So a query RPC (queryIssues/queryIssueDetail/queryPulls) that interleaves BETWEEN
6
+ // seedBatch messages would run against the truncated, partially-repopulated transaction and briefly
7
+ // hand the UI incomplete results — a regression from the old synchronous one-shot seed, which no read
8
+ // could interleave. We can't simply REJECT such reads: both consumers (use-replica-issues.ts refetch +
9
+ // onChanged) treat any query rejection as a hard "Replica error". So we DEFER them instead — a read that
10
+ // arrives mid-seed waits until the seed settles, then runs against a COMPLETE database. Both terminal
11
+ // paths leave a complete DB: commit publishes the fresh snapshot; abort ROLLBACKs to the prior complete
12
+ // snapshot. Either way a released read sees whole data, never a partial one.
13
+ //
14
+ // Pure + injectable so it is unit-testable (test/seed-read-gate.test.ts) without the wasm/OPFS Worker.
15
+ export class SeedReadGate {
16
+ /** Resolvers for reads deferred during the current seed; drained on settle(). */
17
+ waiters = [];
18
+ /**
19
+ * Return a promise a read should await before touching the DB. When no seed is open the caller passes
20
+ * `seeding=false` and gets an already-resolved promise (zero overhead on the hot read path); while a
21
+ * seed is open it resolves only once settle() runs.
22
+ */
23
+ whenReadable(seeding) {
24
+ if (!seeding)
25
+ return Promise.resolve();
26
+ return new Promise((resolve) => this.waiters.push(resolve));
27
+ }
28
+ /** Release every read deferred during the just-finished seed — call on BOTH commit and abort. */
29
+ settle() {
30
+ const waiters = this.waiters;
31
+ this.waiters = [];
32
+ for (const resolve of waiters)
33
+ resolve();
34
+ }
35
+ }
36
+ //# sourceMappingURL=seed-read-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-read-gate.js","sourceRoot":"","sources":["../../../src/replica/browser/seed-read-gate.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,EAAE;AACF,8FAA8F;AAC9F,uGAAuG;AACvG,wGAAwG;AACxG,oGAAoG;AACpG,sGAAsG;AACtG,uGAAuG;AACvG,yGAAyG;AACzG,sGAAsG;AACtG,wGAAwG;AACxG,6EAA6E;AAC7E,EAAE;AACF,uGAAuG;AAEvG,MAAM,OAAO,YAAY;IACvB,iFAAiF;IACzE,OAAO,GAAsB,EAAE,CAAC;IAExC;;;;OAIG;IACH,YAAY,CAAC,OAAgB;QAC3B,IAAI,CAAC,OAAO;YAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QACvC,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;IACpE,CAAC;IAED,iGAAiG;IACjG,MAAM;QACJ,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC;QAC7B,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;QAClB,KAAK,MAAM,OAAO,IAAI,OAAO;YAAE,OAAO,EAAE,CAAC;IAC3C,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ import type { ReplicaDb } from "./ports.js";
2
+ import type { WireChange } from "./protocol.js";
3
+ /** The three replica write primitives the seed drives, injected so tests can fake them. */
4
+ export interface SeedOps {
5
+ truncate: (db: ReplicaDb) => void;
6
+ apply: (db: ReplicaDb, rec: WireChange) => boolean;
7
+ setCursor: (db: ReplicaDb, cursor: number) => void;
8
+ }
9
+ export declare class SeedSession {
10
+ private readonly db;
11
+ private readonly ops;
12
+ private open;
13
+ constructor(db: ReplicaDb, ops: SeedOps);
14
+ /** Open the transaction and wipe the replica (but not sync_meta) so the fresh snapshot replaces it. */
15
+ begin(): void;
16
+ /** Apply one bounded batch of rows into the open transaction. */
17
+ batch(rows: WireChange[]): void;
18
+ /** Persist the terminal cursor, then COMMIT the whole seed atomically. Returns the cursor. */
19
+ commit(cursor: number): number;
20
+ /** Roll back a partial seed on error — leaves the prior cursor (sync_meta) intact. Idempotent. */
21
+ abort(): void;
22
+ }
23
+ //# sourceMappingURL=seed-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-session.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/seed-session.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,2FAA2F;AAC3F,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,CAAC,EAAE,EAAE,SAAS,KAAK,IAAI,CAAC;IAClC,KAAK,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,GAAG,EAAE,UAAU,KAAK,OAAO,CAAC;IACnD,SAAS,EAAE,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACpD;AAED,qBAAa,WAAW;IAIpB,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAJtB,OAAO,CAAC,IAAI,CAAS;gBAGF,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,OAAO;IAG/B,uGAAuG;IACvG,KAAK,IAAI,IAAI;IAQb,iEAAiE;IACjE,KAAK,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,IAAI;IAK/B,8FAA8F;IAC9F,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM;IAQ9B,kGAAkG;IAClG,KAAK,IAAI,IAAI;CAUd"}
@@ -0,0 +1,57 @@
1
+ // replica/seed-session.ts — the pure transaction state machine behind the batched OPFS seed (CTC-132).
2
+ //
3
+ // The streamed seed spans MULTIPLE Worker messages (seedBegin → seedBatch* → seedCommit / seedAbort),
4
+ // so — unlike the old one-shot `seed` that ran inside a single callback-scoped `transaction()` — the
5
+ // SQLite transaction must stay open ACROSS those messages. This class owns that: it opens the txn +
6
+ // truncates on `begin`, applies rows per `batch`, and sets the cursor + COMMITs on `commit` (or
7
+ // ROLLBACKs on `abort`). The Worker (db.worker.ts) is single-threaded and messages are serialized, so
8
+ // the open transaction is safe between batches.
9
+ //
10
+ // The truncate/apply/setCursor ops are INJECTED so this seam is unit-testable against a fake ReplicaDb
11
+ // (test/seed-session.test.ts); the Worker passes the real apply.ts bindings.
12
+ export class SeedSession {
13
+ db;
14
+ ops;
15
+ open = false;
16
+ constructor(db, ops) {
17
+ this.db = db;
18
+ this.ops = ops;
19
+ }
20
+ /** Open the transaction and wipe the replica (but not sync_meta) so the fresh snapshot replaces it. */
21
+ begin() {
22
+ this.db.run("BEGIN");
23
+ // Mark open BEFORE truncate: once BEGIN succeeds the transaction is live, so abort() must be able
24
+ // to ROLLBACK it even if truncate throws mid-begin (else the txn leaks). CTC-132 review finding A.
25
+ this.open = true;
26
+ this.ops.truncate(this.db);
27
+ }
28
+ /** Apply one bounded batch of rows into the open transaction. */
29
+ batch(rows) {
30
+ if (!this.open)
31
+ throw new Error("seedBatch before seedBegin");
32
+ for (const rec of rows)
33
+ this.ops.apply(this.db, rec);
34
+ }
35
+ /** Persist the terminal cursor, then COMMIT the whole seed atomically. Returns the cursor. */
36
+ commit(cursor) {
37
+ if (!this.open)
38
+ throw new Error("seedCommit before seedBegin");
39
+ this.ops.setCursor(this.db, cursor);
40
+ this.db.run("COMMIT");
41
+ this.open = false;
42
+ return cursor;
43
+ }
44
+ /** Roll back a partial seed on error — leaves the prior cursor (sync_meta) intact. Idempotent. */
45
+ abort() {
46
+ if (this.open) {
47
+ try {
48
+ this.db.run("ROLLBACK");
49
+ }
50
+ catch {
51
+ // ROLLBACK can throw if the txn already aborted — swallow so the original error surfaces.
52
+ }
53
+ this.open = false;
54
+ }
55
+ }
56
+ }
57
+ //# sourceMappingURL=seed-session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seed-session.js","sourceRoot":"","sources":["../../../src/replica/browser/seed-session.ts"],"names":[],"mappings":"AAAA,uGAAuG;AACvG,EAAE;AACF,sGAAsG;AACtG,qGAAqG;AACrG,oGAAoG;AACpG,gGAAgG;AAChG,sGAAsG;AACtG,gDAAgD;AAChD,EAAE;AACF,uGAAuG;AACvG,6EAA6E;AAY7E,MAAM,OAAO,WAAW;IAIH;IACA;IAJX,IAAI,GAAG,KAAK,CAAC;IAErB,YACmB,EAAa,EACb,GAAY;QADZ,OAAE,GAAF,EAAE,CAAW;QACb,QAAG,GAAH,GAAG,CAAS;IAC5B,CAAC;IAEJ,uGAAuG;IACvG,KAAK;QACH,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACrB,kGAAkG;QAClG,mGAAmG;QACnG,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC7B,CAAC;IAED,iEAAiE;IACjE,KAAK,CAAC,IAAkB;QACtB,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC;QAC9D,KAAK,MAAM,GAAG,IAAI,IAAI;YAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;IACvD,CAAC;IAED,8FAA8F;IAC9F,MAAM,CAAC,MAAc;QACnB,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;QAC/D,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC;QACpC,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QACtB,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC;QAClB,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,kGAAkG;IAClG,KAAK;QACH,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;YACd,IAAI,CAAC;gBACH,IAAI,CAAC,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACP,0FAA0F;YAC5F,CAAC;YACD,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC;QACpB,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,24 @@
1
+ import type { WireChange } from "./protocol.js";
2
+ /** One server /snapshot page; the memory/round-trip knob (mirrors the server-side PAGE_SIZE, CTC-63). */
3
+ export declare const SEED_BATCH_ROWS = 1000;
4
+ /** A yielded item: a bounded batch of rows, or the terminal cursor (always the LAST item). */
5
+ export type SnapshotItem = {
6
+ kind: "batch";
7
+ rows: WireChange[];
8
+ } | {
9
+ kind: "cursor";
10
+ cursor: number;
11
+ };
12
+ /**
13
+ * Decode a /snapshot NDJSON body stream into bounded batches of `WireChange` rows, yielding a final
14
+ * `{kind:"cursor"}` item once the terminal cursor line is seen. Throws if the stream ends before a
15
+ * terminal cursor line (a truncated snapshot must not be mistaken for a complete one).
16
+ *
17
+ * At most `batchSize` rows plus the current partial line are held in memory at any moment.
18
+ */
19
+ export declare function streamSnapshotBatches(body: ReadableStream<Uint8Array>, batchSize?: number,
20
+ /** Fired after every non-terminal `read()` — i.e. whenever the body actually delivered bytes. The
21
+ * seed's idle timeout re-arms on this, which is what distinguishes a slow-but-live ~100 MB snapshot
22
+ * from a stalled one. Optional, so every existing caller compiles unchanged. */
23
+ onChunk?: () => void): AsyncGenerator<SnapshotItem>;
24
+ //# sourceMappingURL=snapshot-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot-stream.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/snapshot-stream.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGhD,yGAAyG;AACzG,eAAO,MAAM,eAAe,OAAO,CAAC;AAepC,8FAA8F;AAC9F,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,UAAU,EAAE,CAAA;CAAE,GACrC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAOvC;;;;;;GAMG;AACH,wBAAuB,qBAAqB,CAC1C,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,SAAS,GAAE,MAAwB;AACnC;;iFAEiF;AACjF,OAAO,CAAC,EAAE,MAAM,IAAI,GACnB,cAAc,CAAC,YAAY,CAAC,CA4E9B"}
@@ -0,0 +1,113 @@
1
+ // replica/snapshot-stream.ts — a pure, unit-testable streaming decoder for the /snapshot NDJSON feed
2
+ // (CTC-132). It turns the response `ReadableStream<Uint8Array>` into BOUNDED batches of parsed
3
+ // `WireChange` rows plus the terminal cursor, decoding incrementally with `TextDecoder({stream:true})`
4
+ // and buffering only the current partial line — never the whole body, never a whole-array parse.
5
+ //
6
+ // This is the browser consumer's OOM fix: the old path did `await res.text()` on the ~74 MB body, then
7
+ // `body.split("\n")` + a `JSON.parse` per line into a 44.5k-element array, then structured-cloned that
8
+ // whole array across `postMessage`. Here at most `batchSize` rows (plus the current partial line) are
9
+ // resident at once, so peak memory is O(batch), not O(snapshot).
10
+ //
11
+ // No OPFS / Worker import lives here on purpose — the module stays runtime-agnostic and tree-shakes
12
+ // cleanly, and is fully covered in vitest/jsdom (test/snapshot-stream.test.ts).
13
+ import { requirePositiveInt } from "./validate.js";
14
+ /** One server /snapshot page; the memory/round-trip knob (mirrors the server-side PAGE_SIZE, CTC-63). */
15
+ export const SEED_BATCH_ROWS = 1000;
16
+ /** Discriminate the two wire line shapes on a numeric `cursor` field (the terminal line carries it). */
17
+ function isCursorLine(o) {
18
+ return typeof o.cursor === "number";
19
+ }
20
+ /**
21
+ * Decode a /snapshot NDJSON body stream into bounded batches of `WireChange` rows, yielding a final
22
+ * `{kind:"cursor"}` item once the terminal cursor line is seen. Throws if the stream ends before a
23
+ * terminal cursor line (a truncated snapshot must not be mistaken for a complete one).
24
+ *
25
+ * At most `batchSize` rows plus the current partial line are held in memory at any moment.
26
+ */
27
+ export async function* streamSnapshotBatches(body, batchSize = SEED_BATCH_ROWS,
28
+ /** Fired after every non-terminal `read()` — i.e. whenever the body actually delivered bytes. The
29
+ * seed's idle timeout re-arms on this, which is what distinguishes a slow-but-live ~100 MB snapshot
30
+ * from a stalled one. Optional, so every existing caller compiles unchanged. */
31
+ onChunk) {
32
+ // Validate BEFORE reading a byte (CTC-114 review round 12). This helper is exported from
33
+ // `@catalyst-cloud/sdk/browser`, so `batchSize` is consumer-supplied. `NaN`/`Infinity` make
34
+ // `batch.length >= batchSize` permanently false, so no batch is ever yielded: every parsed row of a
35
+ // ~100 MB snapshot accumulates and is finally structured-cloned across the worker boundary in one
36
+ // message. The bounded-memory guarantee this module exists for would be gone, silently — and an OOM
37
+ // is exactly what CTC-132 introduced streaming to prevent.
38
+ requirePositiveInt("streamSnapshotBatches", "batchSize", batchSize);
39
+ const reader = body.getReader();
40
+ const decoder = new TextDecoder();
41
+ let buf = "";
42
+ let batch = [];
43
+ let sawCursor = false;
44
+ let cursor = 0;
45
+ // Parse one complete line. Data lines accumulate into `batch`; the terminal cursor line records the
46
+ // cursor. Returns a full batch to yield when the batch reaches `batchSize`, else null.
47
+ const handleLine = (line) => {
48
+ if (line.length === 0)
49
+ return null; // skip blank lines
50
+ const o = JSON.parse(line);
51
+ if (isCursorLine(o)) {
52
+ sawCursor = true;
53
+ cursor = o.cursor;
54
+ return null;
55
+ }
56
+ batch.push({ entity: o.entity, op: o.op, row: o.row });
57
+ if (batch.length >= batchSize) {
58
+ const full = batch;
59
+ batch = [];
60
+ return { kind: "batch", rows: full };
61
+ }
62
+ return null;
63
+ };
64
+ let completed = false;
65
+ try {
66
+ for (;;) {
67
+ const { done, value } = await reader.read();
68
+ if (done)
69
+ break;
70
+ onChunk?.();
71
+ buf += decoder.decode(value, { stream: true });
72
+ let nl;
73
+ while ((nl = buf.indexOf("\n")) >= 0) {
74
+ const line = buf.slice(0, nl);
75
+ buf = buf.slice(nl + 1);
76
+ const emit = handleLine(line);
77
+ if (emit)
78
+ yield emit;
79
+ }
80
+ }
81
+ // Flush any final line with no trailing newline (plus any bytes the decoder was still holding).
82
+ const tail = (buf + decoder.decode()).trim();
83
+ if (tail.length > 0) {
84
+ const emit = handleLine(tail);
85
+ if (emit)
86
+ yield emit;
87
+ }
88
+ // Emit the last partial batch before the cursor so the cursor is always the FINAL yielded item.
89
+ if (batch.length > 0)
90
+ yield { kind: "batch", rows: batch };
91
+ if (!sawCursor)
92
+ throw new Error("/snapshot stream ended without a terminal cursor line");
93
+ yield { kind: "cursor", cursor };
94
+ completed = true;
95
+ }
96
+ finally {
97
+ // On any ABNORMAL exit — a parse throw, a downstream `seedBatch` rejection, or an early consumer
98
+ // `return`/`break` — CANCEL the body so the ~74 MB /snapshot response and its Durable Object
99
+ // generator stop draining server-side; releaseLock alone leaves the underlying fetch stream live
100
+ // (CTC-132 review finding C). On normal completion the stream is already drained, so we only
101
+ // release the lock. reader.cancel() keeps the lock, so releaseLock() still runs on every path.
102
+ if (!completed) {
103
+ try {
104
+ await reader.cancel();
105
+ }
106
+ catch {
107
+ // The stream already errored/closed — nothing left to cancel; fall through to releaseLock.
108
+ }
109
+ }
110
+ reader.releaseLock();
111
+ }
112
+ }
113
+ //# sourceMappingURL=snapshot-stream.js.map