@catalyst-cloud/sdk 0.8.1 → 0.8.3

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.
@@ -1,5 +1,37 @@
1
1
  import { type OpenedReplica } from "./ports.js";
2
2
  export type { OpenedReplica } from "./ports.js";
3
+ /**
4
+ * Is this throw "the bytes on disk are not a usable database"? (CTC-337)
5
+ *
6
+ * ⚠️ THE ALLOWLIST IS THE SAFETY PROPERTY, not the recovery it guards. A `true` here authorizes
7
+ * DESTROYING the pool, so it must match ONLY genuine corruption. In particular it must never match
8
+ * `NoModificationAllowedError` — that is a second tab (or a not-yet-released worker) holding the
9
+ * SyncAccessHandles, and wiping on it would delete a database another live tab is actively using,
10
+ * turning a self-healing recovery into data loss for someone else. Contention is transient and the
11
+ * caller retries; corruption is permanent and only a wipe clears it. Anything unrecognised RETHROWS.
12
+ */
13
+ export declare function isCorruptDatabaseError(err: unknown): boolean;
14
+ /** The slice of SAHPoolUtil the recovery needs — narrowed so a test can supply a fake pool. */
15
+ export interface RecoverablePool {
16
+ OpfsSAHPoolDb: new (dbPath: string) => unknown;
17
+ wipeFiles: () => Promise<void>;
18
+ }
19
+ /**
20
+ * Open the database, and if the FILE ITSELF is corrupt, destroy it and open a fresh one — ONCE.
21
+ *
22
+ * Safe in a way it would not be for most databases: this replica is 100% DERIVED. Every row came from
23
+ * `/snapshot` plus the change feed, so the local copy holds nothing that does not exist upstream, and
24
+ * re-seeding is the ordinary cold-start path rather than a recovery hack.
25
+ *
26
+ * Without this, one corrupt page bricked the replica PERMANENTLY. The corruption survives reloads, so
27
+ * `start()` threw on every subsequent boot and the reader sat behind a red pill forever unless they
28
+ * knew to clear origin storage by hand — a disposable cache that could not dispose of itself. That is
29
+ * exactly how CTC-337 presented: `SQLITE_CORRUPT: database disk image is malformed`, every load.
30
+ *
31
+ * Retries exactly once. A second failure after a wipe is not corruption we can clear — it is a broken
32
+ * environment (no quota, VFS refusing to register) — and looping would turn it into a hang.
33
+ */
34
+ export declare function openWithCorruptionRecovery(pool: RecoverablePool, dbPath: string, build: (db: unknown) => OpenedReplica, log?: (message: string) => void): Promise<OpenedReplica>;
3
35
  /**
4
36
  * Open the OPFS SAHPool replica at `dbPath` (absolute, leading slash REQUIRED) and bring its schema up
5
37
  * to date via the shared migration bundle. Async — wasm init + SAHPool acquire are async, unlike the
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-db.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/sqlite-db.ts"],"names":[],"mappings":"AAaA,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAEpE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAS3F"}
1
+ {"version":3,"file":"sqlite-db.d.ts","sourceRoot":"","sources":["../../../src/replica/browser/sqlite-db.ts"],"names":[],"mappings":"AAaA,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AAEpE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAc5D;AAED,+FAA+F;AAC/F,MAAM,WAAW,eAAe;IAC9B,aAAa,EAAE,KAAK,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAC/C,SAAS,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,0BAA0B,CAC9C,IAAI,EAAE,eAAe,EACrB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,CAAC,EAAE,EAAE,OAAO,KAAK,aAAa,EACrC,GAAG,GAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAA6B,GACtD,OAAO,CAAC,aAAa,CAAC,CAaxB;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAU3F"}
@@ -10,6 +10,59 @@
10
10
  // not the primary one.
11
11
  import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
12
12
  import { buildOpenedReplica } from "./ports.js";
13
+ /**
14
+ * Is this throw "the bytes on disk are not a usable database"? (CTC-337)
15
+ *
16
+ * ⚠️ THE ALLOWLIST IS THE SAFETY PROPERTY, not the recovery it guards. A `true` here authorizes
17
+ * DESTROYING the pool, so it must match ONLY genuine corruption. In particular it must never match
18
+ * `NoModificationAllowedError` — that is a second tab (or a not-yet-released worker) holding the
19
+ * SyncAccessHandles, and wiping on it would delete a database another live tab is actively using,
20
+ * turning a self-healing recovery into data loss for someone else. Contention is transient and the
21
+ * caller retries; corruption is permanent and only a wipe clears it. Anything unrecognised RETHROWS.
22
+ */
23
+ export function isCorruptDatabaseError(err) {
24
+ const text = err instanceof Error ? `${err.name}: ${err.message}` : typeof err === "string" ? err : "";
25
+ if (text === "")
26
+ return false;
27
+ // Explicit rather than relying on the absence of a corruption marker: this is the case that must
28
+ // not wipe, so it should be impossible to reach the allowlist below by accident.
29
+ if (/NoModificationAllowedError|InvalidStateError/i.test(text))
30
+ return false;
31
+ return (/SQLITE_CORRUPT/i.test(text) ||
32
+ /SQLITE_NOTADB/i.test(text) ||
33
+ /database disk image is malformed/i.test(text) ||
34
+ /file is not a database/i.test(text) ||
35
+ /malformed database schema/i.test(text));
36
+ }
37
+ /**
38
+ * Open the database, and if the FILE ITSELF is corrupt, destroy it and open a fresh one — ONCE.
39
+ *
40
+ * Safe in a way it would not be for most databases: this replica is 100% DERIVED. Every row came from
41
+ * `/snapshot` plus the change feed, so the local copy holds nothing that does not exist upstream, and
42
+ * re-seeding is the ordinary cold-start path rather than a recovery hack.
43
+ *
44
+ * Without this, one corrupt page bricked the replica PERMANENTLY. The corruption survives reloads, so
45
+ * `start()` threw on every subsequent boot and the reader sat behind a red pill forever unless they
46
+ * knew to clear origin storage by hand — a disposable cache that could not dispose of itself. That is
47
+ * exactly how CTC-337 presented: `SQLITE_CORRUPT: database disk image is malformed`, every load.
48
+ *
49
+ * Retries exactly once. A second failure after a wipe is not corruption we can clear — it is a broken
50
+ * environment (no quota, VFS refusing to register) — and looping would turn it into a hang.
51
+ */
52
+ export async function openWithCorruptionRecovery(pool, dbPath, build, log = (m) => console.warn(m)) {
53
+ try {
54
+ return build(new pool.OpfsSAHPoolDb(dbPath));
55
+ }
56
+ catch (err) {
57
+ if (!isCorruptDatabaseError(err))
58
+ throw err;
59
+ // Loud on purpose. Healing silently would hide that corruption HAPPENED, and the RATE of it is
60
+ // the signal worth having — a replica that quietly re-seeds every session is a different bug.
61
+ log(`[replica] OPFS database ${dbPath} is corrupt (${String(err)}); wiping and re-seeding — the replica is derived, so nothing is lost`);
62
+ await pool.wipeFiles();
63
+ return build(new pool.OpfsSAHPoolDb(dbPath));
64
+ }
65
+ }
13
66
  /**
14
67
  * Open the OPFS SAHPool replica at `dbPath` (absolute, leading slash REQUIRED) and bring its schema up
15
68
  * to date via the shared migration bundle. Async — wasm init + SAHPool acquire are async, unlike the
@@ -22,7 +75,6 @@ export async function openReplica(dbPath, directory) {
22
75
  initialCapacity: 6, // ≥ 2× the db count (one db + journals).
23
76
  directory, // one OPFS dir per app, so engines don't collide.
24
77
  });
25
- const db = new poolUtil.OpfsSAHPoolDb(dbPath);
26
- return buildOpenedReplica(db);
78
+ return openWithCorruptionRecovery(poolUtil, dbPath, (db) => buildOpenedReplica(db));
27
79
  }
28
80
  //# sourceMappingURL=sqlite-db.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite-db.js","sourceRoot":"","sources":["../../../src/replica/browser/sqlite-db.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,mGAAmG;AACnG,sGAAsG;AACtG,0BAA0B;AAC1B,EAAE;AACF,qGAAqG;AACrG,kGAAkG;AAClG,kGAAkG;AAClG,qGAAqG;AACrG,uBAAuB;AAEvB,OAAO,iBAAiB,MAAM,yBAAyB,CAAC;AAExD,OAAO,EAAE,kBAAkB,EAAsB,MAAM,YAAY,CAAC;AAIpE;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAc,EAAE,SAAiB;IACjE,MAAM,OAAO,GAAG,MAAM,iBAAiB,EAAE,CAAC;IAC1C,MAAM,QAAQ,GAAgB,MAAM,OAAO,CAAC,qBAAqB,CAAC;QAChE,WAAW,EAAE,KAAK,EAAE,8DAA8D;QAClF,eAAe,EAAE,CAAC,EAAE,yCAAyC;QAC7D,SAAS,EAAE,kDAAkD;KAC9D,CAAC,CAAC;IACH,MAAM,EAAE,GAAG,IAAI,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IAC9C,OAAO,kBAAkB,CAAC,EAAE,CAAC,CAAC;AAChC,CAAC"}
1
+ {"version":3,"file":"sqlite-db.js","sourceRoot":"","sources":["../../../src/replica/browser/sqlite-db.ts"],"names":[],"mappings":"AAAA,mGAAmG;AACnG,mGAAmG;AACnG,sGAAsG;AACtG,0BAA0B;AAC1B,EAAE;AACF,qGAAqG;AACrG,kGAAkG;AAClG,kGAAkG;AAClG,qGAAqG;AACrG,uBAAuB;AAEvB,OAAO,iBAAiB,MAAM,yBAAyB,CAAC;AAExD,OAAO,EAAE,kBAAkB,EAAsB,MAAM,YAAY,CAAC;AAIpE;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CAAC,GAAY;IACjD,MAAM,IAAI,GACR,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,IAAI,KAAK,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IAC5F,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAC9B,iGAAiG;IACjG,iFAAiF;IACjF,IAAI,+CAA+C,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7E,OAAO,CACL,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;QAC5B,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC;QAC3B,mCAAmC,CAAC,IAAI,CAAC,IAAI,CAAC;QAC9C,yBAAyB,CAAC,IAAI,CAAC,IAAI,CAAC;QACpC,4BAA4B,CAAC,IAAI,CAAC,IAAI,CAAC,CACxC,CAAC;AACJ,CAAC;AAQD;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,IAAqB,EACrB,MAAc,EACd,KAAqC,EACrC,MAAiC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IAEvD,IAAI,CAAC;QACH,OAAO,KAAK,CAAC,IAAI,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,sBAAsB,CAAC,GAAG,CAAC;YAAE,MAAM,GAAG,CAAC;QAC5C,+FAA+F;QAC/F,8FAA8F;QAC9F,GAAG,CACD,2BAA2B,MAAM,gBAAgB,MAAM,CAAC,GAAG,CAAC,uEAAuE,CACpI,CAAC;QACF,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAC,IAAI,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAc,EAAE,SAAiB;IACjE,MAAM,OAAO,GAAG,MAAM,iBAAiB,EAAE,CAAC;IAC1C,MAAM,QAAQ,GAAgB,MAAM,OAAO,CAAC,qBAAqB,CAAC;QAChE,WAAW,EAAE,KAAK,EAAE,8DAA8D;QAClF,eAAe,EAAE,CAAC,EAAE,yCAAyC;QAC7D,SAAS,EAAE,kDAAkD;KAC9D,CAAC,CAAC;IACH,OAAO,0BAA0B,CAAC,QAAsC,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,CACvF,kBAAkB,CAAC,EAA8C,CAAC,CACnE,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Keep a live local copy of your Linear and GitHub project data \u2014 pushed in real time, without polling rate limits or webhook tunnels.",
5
5
  "license": "MIT",
6
6
  "author": "Coalesce Labs",
@@ -78,7 +78,7 @@
78
78
  "dependencies": {
79
79
  "@catalyst-cloud/read-model": "^0.1.0",
80
80
  "@catalyst-cloud/replicate": "^0.1.3",
81
- "@catalyst-cloud/schema": "^0.1.3"
81
+ "@catalyst-cloud/schema": "0.1.10"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@opentelemetry/api": ">=1.3.0 <2",