orez 0.4.24 → 0.4.26

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * namespace routing primitives for the Cloudflare Durable Object deploy.
3
+ *
4
+ * a multi-tenant CF/orez deploy shards data into one DO instance per tenant
5
+ * namespace (`ns:<scope>-<id>`) plus a control-plane `singleton`. the worker
6
+ * tiers carry the chosen namespace in a header (and re-stamp it so an inbound
7
+ * value is never trusted) and must validate its shape before routing — an
8
+ * unvalidated namespace would let a client mint unbounded DO instances.
9
+ *
10
+ * the deployed worker entry classes are bundled strings in the consumer's
11
+ * deploy integration (awkward to unit-test), so the routing decision and the
12
+ * security-relevant shape validation live here as pure functions, tested in
13
+ * cf-do-shim.test.ts. consumers import these into their worker shims instead of
14
+ * copy-pasting the validation regex at every routing site.
15
+ */
16
+ export interface NamespaceRoutingOptions {
17
+ /** allowed namespace scope prefixes. default `['proj', 'test']`. */
18
+ scopes?: readonly string[];
19
+ /**
20
+ * namespace values that resolve to the control-plane `singleton` instance,
21
+ * in addition to the empty string (which is always the singleton).
22
+ */
23
+ controlPlaneNamespaces?: readonly string[];
24
+ /** request header the worker tiers carry the namespace in. default `x-orez-ns`. */
25
+ nsHeader?: string;
26
+ }
27
+ /**
28
+ * true when `ns` is a structurally valid tenant namespace: a configured scope
29
+ * prefix followed by a 1-64 char `[A-Za-z0-9_-]` id. this is the gate that
30
+ * keeps a stray header from minting an unbounded DO instance, so every routing
31
+ * site that forwards a namespace should run it.
32
+ */
33
+ export declare function isValidNamespace(ns: string, opts?: NamespaceRoutingOptions): boolean;
34
+ /**
35
+ * resolve a raw namespace string to a DO instance name:
36
+ * - `''` or a control-plane alias -> `'singleton'`
37
+ * - a valid tenant namespace -> `'ns:<ns>'`
38
+ * - anything else -> `null` (caller should reject the request)
39
+ */
40
+ export declare function doInstanceName(ns: string, opts?: NamespaceRoutingOptions): string | null;
41
+ interface HeaderReader {
42
+ get(name: string): string | null;
43
+ }
44
+ /**
45
+ * read the namespace from a request (the configured header, falling back to the
46
+ * `?ns=` query param) and resolve it to a DO instance name. returns `null` for a
47
+ * structurally invalid namespace so the worker can reply 400 instead of routing.
48
+ */
49
+ export declare function doInstanceNameForRequest(request: {
50
+ headers: HeaderReader;
51
+ }, url: {
52
+ searchParams: HeaderReader;
53
+ }, opts?: NamespaceRoutingOptions): string | null;
54
+ export {};
55
+ //# sourceMappingURL=cf-do-shim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cf-do-shim.d.ts","sourceRoot":"","sources":["../../src/worker/cf-do-shim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC1B;;;OAGG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC1C,mFAAmF;IACnF,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,IAAI,GAAE,uBAA4B,GACjC,OAAO,CAET;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,EAAE,EAAE,MAAM,EACV,IAAI,GAAE,uBAA4B,GACjC,MAAM,GAAG,IAAI,CAKf;AAED,UAAU,YAAY;IACpB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE;IAAE,OAAO,EAAE,YAAY,CAAA;CAAE,EAClC,GAAG,EAAE;IAAE,YAAY,EAAE,YAAY,CAAA;CAAE,EACnC,IAAI,GAAE,uBAA4B,GACjC,MAAM,GAAG,IAAI,CAIf"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * namespace routing primitives for the Cloudflare Durable Object deploy.
3
+ *
4
+ * a multi-tenant CF/orez deploy shards data into one DO instance per tenant
5
+ * namespace (`ns:<scope>-<id>`) plus a control-plane `singleton`. the worker
6
+ * tiers carry the chosen namespace in a header (and re-stamp it so an inbound
7
+ * value is never trusted) and must validate its shape before routing — an
8
+ * unvalidated namespace would let a client mint unbounded DO instances.
9
+ *
10
+ * the deployed worker entry classes are bundled strings in the consumer's
11
+ * deploy integration (awkward to unit-test), so the routing decision and the
12
+ * security-relevant shape validation live here as pure functions, tested in
13
+ * cf-do-shim.test.ts. consumers import these into their worker shims instead of
14
+ * copy-pasting the validation regex at every routing site.
15
+ */
16
+ /** default namespace scope prefixes (`proj-<id>`, `test-<id>`). */
17
+ const DEFAULT_SCOPES = ['proj', 'test'];
18
+ function escapeForRegExp(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
21
+ function namespacePattern(scopes) {
22
+ const group = scopes.map(escapeForRegExp).join('|');
23
+ return new RegExp(`^(?:${group})-[A-Za-z0-9_-]{1,64}$`);
24
+ }
25
+ /**
26
+ * true when `ns` is a structurally valid tenant namespace: a configured scope
27
+ * prefix followed by a 1-64 char `[A-Za-z0-9_-]` id. this is the gate that
28
+ * keeps a stray header from minting an unbounded DO instance, so every routing
29
+ * site that forwards a namespace should run it.
30
+ */
31
+ export function isValidNamespace(ns, opts = {}) {
32
+ return namespacePattern(opts.scopes ?? DEFAULT_SCOPES).test(ns);
33
+ }
34
+ /**
35
+ * resolve a raw namespace string to a DO instance name:
36
+ * - `''` or a control-plane alias -> `'singleton'`
37
+ * - a valid tenant namespace -> `'ns:<ns>'`
38
+ * - anything else -> `null` (caller should reject the request)
39
+ */
40
+ export function doInstanceName(ns, opts = {}) {
41
+ if (!ns)
42
+ return 'singleton';
43
+ if ((opts.controlPlaneNamespaces ?? []).includes(ns))
44
+ return 'singleton';
45
+ if (!isValidNamespace(ns, opts))
46
+ return null;
47
+ return 'ns:' + ns;
48
+ }
49
+ /**
50
+ * read the namespace from a request (the configured header, falling back to the
51
+ * `?ns=` query param) and resolve it to a DO instance name. returns `null` for a
52
+ * structurally invalid namespace so the worker can reply 400 instead of routing.
53
+ */
54
+ export function doInstanceNameForRequest(request, url, opts = {}) {
55
+ const ns = request.headers.get(opts.nsHeader ?? 'x-orez-ns') || url.searchParams.get('ns') || '';
56
+ return doInstanceName(ns, opts);
57
+ }
58
+ //# sourceMappingURL=cf-do-shim.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cf-do-shim.js","sourceRoot":"","sources":["../../src/worker/cf-do-shim.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,mEAAmE;AACnE,MAAM,cAAc,GAAG,CAAC,MAAM,EAAE,MAAM,CAAU,CAAA;AAchD,SAAS,eAAe,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAA;AACrD,CAAC;AAED,SAAS,gBAAgB,CAAC,MAAyB;IACjD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACnD,OAAO,IAAI,MAAM,CAAC,OAAO,KAAK,wBAAwB,CAAC,CAAA;AACzD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAC9B,EAAU,EACV,OAAgC,EAAE;IAElC,OAAO,gBAAgB,CAAC,IAAI,CAAC,MAAM,IAAI,cAAc,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,EAAU,EACV,OAAgC,EAAE;IAElC,IAAI,CAAC,EAAE;QAAE,OAAO,WAAW,CAAA;IAC3B,IAAI,CAAC,IAAI,CAAC,sBAAsB,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC;QAAE,OAAO,WAAW,CAAA;IACxE,IAAI,CAAC,gBAAgB,CAAC,EAAE,EAAE,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IAC5C,OAAO,KAAK,GAAG,EAAE,CAAA;AACnB,CAAC;AAMD;;;;GAIG;AACH,MAAM,UAAU,wBAAwB,CACtC,OAAkC,EAClC,GAAmC,EACnC,OAAgC,EAAE;IAElC,MAAM,EAAE,GACN,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,WAAW,CAAC,IAAI,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAA;IACvF,OAAO,cAAc,CAAC,EAAE,EAAE,IAAI,CAAC,CAAA;AACjC,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cloudflare DO-SQLite compatibility for a zero-cache embed.
3
+ *
4
+ * zero-cache's embedded SQLite issues storage-engine statements (VACUUM, ATTACH,
5
+ * journal/checkpoint PRAGMAs) that a Durable Object's `ctx.storage.sql` does NOT
6
+ * own — the DO manages journaling + checkpointing itself and rejects them with
7
+ * SQLITE_AUTH, which aborts `/sync`. these helpers make those statements no-op so
8
+ * the embed boots unmodified, while every real read/write passes straight
9
+ * through. pure over the minimal `exec` shape (no `@cloudflare/...` types),
10
+ * tested in zero-cache-do-sqlite.test.ts.
11
+ */
12
+ interface DoExecSql {
13
+ exec(sql: string, ...params: unknown[]): unknown;
14
+ [key: string]: unknown;
15
+ }
16
+ interface DoStorageCtx {
17
+ storage: {
18
+ sql: DoExecSql;
19
+ transactionSync?: (...args: unknown[]) => unknown;
20
+ };
21
+ }
22
+ export declare const DO_FORBIDDEN_SQLITE: RegExp;
23
+ export declare function isDoForbiddenSqlite(sql: unknown): boolean;
24
+ /**
25
+ * patch `sql.exec` in place so EVERY caller (the embed, its sqlite shim,
26
+ * transactionSync callbacks) skips DO-forbidden statements instead of throwing
27
+ * SQLITE_AUTH. idempotent per handle.
28
+ */
29
+ export declare function installDoForbiddenSqliteGuard(sql: DoExecSql): void;
30
+ /**
31
+ * wrap a DO's `storage.sql` for the zero-cache embed: DO-forbidden statements
32
+ * no-op, and `transactionSync` is bound through when the platform exposes it.
33
+ */
34
+ export declare function doSqliteStorage(ctx: DoStorageCtx): {
35
+ exec: (sql: string, ...params: unknown[]) => unknown;
36
+ transactionSync: ((...args: unknown[]) => unknown) | undefined;
37
+ };
38
+ export {};
39
+ //# sourceMappingURL=zero-cache-do-sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-cache-do-sqlite.d.ts","sourceRoot":"","sources":["../../src/worker/zero-cache-do-sqlite.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,UAAU,SAAS;IACjB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAA;IAChD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,UAAU,YAAY;IACpB,OAAO,EAAE;QACP,GAAG,EAAE,SAAS,CAAA;QACd,eAAe,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAA;KAClD,CAAA;CACF;AAGD,eAAO,MAAM,mBAAmB,QAC+H,CAAA;AAE/J,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAEzD;AAID;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,SAAS,GAAG,IAAI,CAgBlE;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,YAAY,GAAG;IAClD,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAA;IACpD,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,CAAA;CAC/D,CAqBA"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Cloudflare DO-SQLite compatibility for a zero-cache embed.
3
+ *
4
+ * zero-cache's embedded SQLite issues storage-engine statements (VACUUM, ATTACH,
5
+ * journal/checkpoint PRAGMAs) that a Durable Object's `ctx.storage.sql` does NOT
6
+ * own — the DO manages journaling + checkpointing itself and rejects them with
7
+ * SQLITE_AUTH, which aborts `/sync`. these helpers make those statements no-op so
8
+ * the embed boots unmodified, while every real read/write passes straight
9
+ * through. pure over the minimal `exec` shape (no `@cloudflare/...` types),
10
+ * tested in zero-cache-do-sqlite.test.ts.
11
+ */
12
+ // storage-engine statements the DO rejects (it owns journaling/checkpointing).
13
+ export const DO_FORBIDDEN_SQLITE = /^\s*(?:VACUUM\b|ATTACH\b|PRAGMA\s+(?:journal_mode|synchronous|page_size|mmap_size|wal_checkpoint|wal_autocheckpoint|locking_mode|temp_store|cache_size)\b)/i;
14
+ export function isDoForbiddenSqlite(sql) {
15
+ return typeof sql === 'string' && DO_FORBIDDEN_SQLITE.test(sql);
16
+ }
17
+ const GUARD_MARK = '__orezDoSqliteGuarded';
18
+ /**
19
+ * patch `sql.exec` in place so EVERY caller (the embed, its sqlite shim,
20
+ * transactionSync callbacks) skips DO-forbidden statements instead of throwing
21
+ * SQLITE_AUTH. idempotent per handle.
22
+ */
23
+ export function installDoForbiddenSqliteGuard(sql) {
24
+ if (!sql || sql[GUARD_MARK])
25
+ return;
26
+ const rawExec = sql.exec.bind(sql);
27
+ sql.exec = (statement, ...params) => {
28
+ if (isDoForbiddenSqlite(statement)) {
29
+ return {
30
+ toArray: () => [],
31
+ rowsRead: 0,
32
+ rowsWritten: 0,
33
+ columnNames: [],
34
+ [Symbol.iterator]: () => [][Symbol.iterator](),
35
+ };
36
+ }
37
+ return rawExec(statement, ...params);
38
+ };
39
+ sql[GUARD_MARK] = true;
40
+ }
41
+ /**
42
+ * wrap a DO's `storage.sql` for the zero-cache embed: DO-forbidden statements
43
+ * no-op, and `transactionSync` is bound through when the platform exposes it.
44
+ */
45
+ export function doSqliteStorage(ctx) {
46
+ const rawExec = ctx.storage.sql.exec.bind(ctx.storage.sql);
47
+ const exec = (sql, ...params) => {
48
+ if (isDoForbiddenSqlite(sql)) {
49
+ return {
50
+ toArray: () => [],
51
+ rowsRead: 0,
52
+ [Symbol.iterator]: () => [][Symbol.iterator](),
53
+ };
54
+ }
55
+ return rawExec(sql, ...params);
56
+ };
57
+ return {
58
+ exec,
59
+ transactionSync: typeof ctx.storage.transactionSync === 'function'
60
+ ? ctx.storage.transactionSync.bind(ctx.storage)
61
+ : undefined,
62
+ };
63
+ }
64
+ //# sourceMappingURL=zero-cache-do-sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-cache-do-sqlite.js","sourceRoot":"","sources":["../../src/worker/zero-cache-do-sqlite.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAcH,+EAA+E;AAC/E,MAAM,CAAC,MAAM,mBAAmB,GAC9B,6JAA6J,CAAA;AAE/J,MAAM,UAAU,mBAAmB,CAAC,GAAY;IAC9C,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,mBAAmB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AACjE,CAAC;AAED,MAAM,UAAU,GAAG,uBAAuB,CAAA;AAE1C;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAAC,GAAc;IAC1D,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC;QAAE,OAAM;IACnC,MAAM,OAAO,GAAI,GAAG,CAAC,IAAuD,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACtF,GAAG,CAAC,IAAI,GAAG,CAAC,SAAiB,EAAE,GAAG,MAAiB,EAAE,EAAE;QACrD,IAAI,mBAAmB,CAAC,SAAS,CAAC,EAAE,CAAC;YACnC,OAAO;gBACL,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE;gBACjB,QAAQ,EAAE,CAAC;gBACX,WAAW,EAAE,CAAC;gBACd,WAAW,EAAE,EAAE;gBACf,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE;aAC/C,CAAA;QACH,CAAC;QACD,OAAO,OAAO,CAAC,SAAS,EAAE,GAAG,MAAM,CAAC,CAAA;IACtC,CAAC,CAAA;IACD,GAAG,CAAC,UAAU,CAAC,GAAG,IAAI,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,GAAiB;IAI/C,MAAM,OAAO,GACX,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,IACjB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IACvB,MAAM,IAAI,GAAG,CAAC,GAAW,EAAE,GAAG,MAAiB,EAAE,EAAE;QACjD,IAAI,mBAAmB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC7B,OAAO;gBACL,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE;gBACjB,QAAQ,EAAE,CAAC;gBACX,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE;aAC/C,CAAA;QACH,CAAC;QACD,OAAO,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,CAAA;IAChC,CAAC,CAAA;IACD,OAAO;QACL,IAAI;QACJ,eAAe,EACb,OAAO,GAAG,CAAC,OAAO,CAAC,eAAe,KAAK,UAAU;YAC/C,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC;YAC/C,CAAC,CAAC,SAAS;KAChB,CAAA;AACH,CAAC"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * self-heal for a zero-cache embed's DO-SQLite replica.
3
+ *
4
+ * zero-cache snapshots its publication into a replica (here, the Durable
5
+ * Object's `ctx.storage.sql`, `ZERO_REPLICA_FILE=':do-sqlite:'`). on a real
6
+ * Postgres that replica lives in a normal file with normal transaction
7
+ * semantics; inside a DO the sqlite shim makes BEGIN/COMMIT/ROLLBACK no-ops
8
+ * (the object auto-commits per I/O turn) and any boot can be killed across an
9
+ * await (the 120s ready-timeout, an eviction, an OOM). that combination leaves
10
+ * three distinct corrupt-replica states that each wedge `/sync` permanently
11
+ * until healed. these functions detect and repair each one; every one of them
12
+ * fixed a specific production incident (see the per-function comments).
13
+ *
14
+ * the replica is *derived* data — the upstream rows live in the SQL DO and are
15
+ * untouched by any wipe here, so dropping the replica only forces zero-cache to
16
+ * re-run initial sync. pure logic over minimal `exec`/`get`/`put` shapes (no
17
+ * `@cloudflare/...` types); the decision logic is unit-tested in
18
+ * zero-cache-replica-repair.test.ts against simulated replica states. consumers
19
+ * call these from their ZeroCacheDO boot sequence with their own storage key +
20
+ * log prefix.
21
+ */
22
+ export interface ReplicaSqlResult {
23
+ toArray(): Array<Record<string, unknown>>;
24
+ }
25
+ export interface ReplicaSqlStorage {
26
+ exec(sql: string, ...params: unknown[]): ReplicaSqlResult;
27
+ }
28
+ export interface ReplicaKvStorage {
29
+ get(key: string): Promise<unknown>;
30
+ put(key: string, value: unknown): Promise<unknown>;
31
+ }
32
+ /**
33
+ * run a SQL statement against the SQL-DO backend (pg-over-DO `/exec`), returning
34
+ * its `{ rows, error }` body. consumers wrap their backend fetch in this shape;
35
+ * an `{ error }` is surfaced (never treated as "no rows", which would silently
36
+ * disable the guards).
37
+ */
38
+ export type BackendExec = (sql: string, params?: unknown[]) => Promise<{
39
+ rows?: Array<Record<string, unknown>>;
40
+ error?: string;
41
+ }>;
42
+ /**
43
+ * drop every non-internal table from the replica SQLite. SQLite internals
44
+ * (`sqlite_*`) and Cloudflare's DO tables (`_cf_*`) are skipped: the DO storage
45
+ * authorizer rejects DROP on `_cf_*` with SQLITE_AUTH, which would abort the
46
+ * whole reset. returns the number of tables dropped.
47
+ */
48
+ export declare function dropReplicaTables(sql: ReplicaSqlStorage): number;
49
+ export declare function resetReplicaIfTableSetChanged(sql: ReplicaSqlStorage, storage: ReplicaKvStorage, opts: {
50
+ schemaVersion: string;
51
+ tables?: Iterable<string>;
52
+ /** durable storage key the last-applied tag is persisted under. */
53
+ tagKey: string;
54
+ }): Promise<void>;
55
+ export declare function repairPartialReplicaInit(sql: ReplicaSqlStorage, opts?: {
56
+ logPrefix?: string;
57
+ }): void;
58
+ export declare function resetReplicaIfChangeLogPoisoned(sql: ReplicaSqlStorage, backendExec: BackendExec, opts: {
59
+ appId: string;
60
+ logPrefix?: string;
61
+ }): Promise<void>;
62
+ export declare function clearChangeStreamerStateIfReplicaUninitialized(sql: ReplicaSqlStorage, backendExec: BackendExec, opts: {
63
+ appId: string;
64
+ logPrefix?: string;
65
+ }): Promise<void>;
66
+ //# sourceMappingURL=zero-cache-replica-repair.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-cache-replica-repair.d.ts","sourceRoot":"","sources":["../../src/worker/zero-cache-replica-repair.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CAC1C;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,gBAAgB,CAAA;CAC1D;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAClC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CACnD;AAED;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG,CACxB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,OAAO,EAAE,KACf,OAAO,CAAC;IAAE,IAAI,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA;AAEvE;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,iBAAiB,GAAG,MAAM,CAUhE;AAWD,wBAAsB,6BAA6B,CACjD,GAAG,EAAE,iBAAiB,EACtB,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE;IACJ,aAAa,EAAE,MAAM,CAAA;IACrB,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACzB,mEAAmE;IACnE,MAAM,EAAE,MAAM,CAAA;CACf,GACA,OAAO,CAAC,IAAI,CAAC,CAWf;AAeD,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,iBAAiB,EACtB,IAAI,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAChC,IAAI,CA8BN;AASD,wBAAsB,+BAA+B,CACnD,GAAG,EAAE,iBAAiB,EACtB,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1C,OAAO,CAAC,IAAI,CAAC,CAoDf;AASD,wBAAsB,8CAA8C,CAClE,GAAG,EAAE,iBAAiB,EACtB,WAAW,EAAE,WAAW,EACxB,IAAI,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1C,OAAO,CAAC,IAAI,CAAC,CA8Bf"}
@@ -0,0 +1,185 @@
1
+ /**
2
+ * self-heal for a zero-cache embed's DO-SQLite replica.
3
+ *
4
+ * zero-cache snapshots its publication into a replica (here, the Durable
5
+ * Object's `ctx.storage.sql`, `ZERO_REPLICA_FILE=':do-sqlite:'`). on a real
6
+ * Postgres that replica lives in a normal file with normal transaction
7
+ * semantics; inside a DO the sqlite shim makes BEGIN/COMMIT/ROLLBACK no-ops
8
+ * (the object auto-commits per I/O turn) and any boot can be killed across an
9
+ * await (the 120s ready-timeout, an eviction, an OOM). that combination leaves
10
+ * three distinct corrupt-replica states that each wedge `/sync` permanently
11
+ * until healed. these functions detect and repair each one; every one of them
12
+ * fixed a specific production incident (see the per-function comments).
13
+ *
14
+ * the replica is *derived* data — the upstream rows live in the SQL DO and are
15
+ * untouched by any wipe here, so dropping the replica only forces zero-cache to
16
+ * re-run initial sync. pure logic over minimal `exec`/`get`/`put` shapes (no
17
+ * `@cloudflare/...` types); the decision logic is unit-tested in
18
+ * zero-cache-replica-repair.test.ts against simulated replica states. consumers
19
+ * call these from their ZeroCacheDO boot sequence with their own storage key +
20
+ * log prefix.
21
+ */
22
+ /**
23
+ * drop every non-internal table from the replica SQLite. SQLite internals
24
+ * (`sqlite_*`) and Cloudflare's DO tables (`_cf_*`) are skipped: the DO storage
25
+ * authorizer rejects DROP on `_cf_*` with SQLITE_AUTH, which would abort the
26
+ * whole reset. returns the number of tables dropped.
27
+ */
28
+ export function dropReplicaTables(sql) {
29
+ const rows = sql.exec("SELECT name FROM sqlite_master WHERE type='table'").toArray();
30
+ let dropped = 0;
31
+ for (const row of rows) {
32
+ const name = String(row.name);
33
+ if (name.startsWith('sqlite_') || name.startsWith('_cf_'))
34
+ continue;
35
+ sql.exec('DROP TABLE IF EXISTS "' + name.replaceAll('"', '""') + '"');
36
+ dropped++;
37
+ }
38
+ return dropped;
39
+ }
40
+ // zero-cache snapshots the publication's tables into its replica ONCE during
41
+ // initial sync and never picks up a table OR COLUMN added afterward — ALTER only
42
+ // feeds the change stream, not the existing snapshot. so a redeploy that evolves
43
+ // the schema leaves the persisted replica stuck on the old shape and every
44
+ // client fails SchemaVersionNotSupported (2026-06-10: file.title/description
45
+ // columns — table set unchanged, so a tables-only tag never reset). key the tag
46
+ // on schemaVersion (a hash of the full deploy-time DDL batch — any
47
+ // table/column/type change) plus the table set, and wipe the replica on change
48
+ // so zero-cache re-runs initial sync over the full publication.
49
+ export async function resetReplicaIfTableSetChanged(sql, storage, opts) {
50
+ const tag = JSON.stringify([opts.schemaVersion, [...(opts.tables || [])].sort()]);
51
+ const lastTag = await storage.get(opts.tagKey);
52
+ // reset whenever the tag differs — including the no-baseline case (lastTag
53
+ // undefined), which covers a DO whose replica was initialized by a deploy that
54
+ // predates this tracking. on a brand-new DO the replica is empty so the drop
55
+ // loop is a no-op; on a stale one it forces a full re-sync.
56
+ if (lastTag !== tag) {
57
+ dropReplicaTables(sql);
58
+ }
59
+ await storage.put(opts.tagKey, tag);
60
+ }
61
+ // repair a PARTIALLY-INITIALIZED replica left by an interrupted embed boot.
62
+ // zero-cache's runSchemaMigrations wraps initial-sync (createReplicationStateTables
63
+ // + the versionHistory row write) in one BEGIN EXCLUSIVE/COMMIT, expecting it to
64
+ // be atomic. but on a CF DO the sqlite shim makes BEGIN/COMMIT/ROLLBACK NO-OPS
65
+ // (the DO auto-commits per I/O turn), and the setup migration is async (it awaits
66
+ // initialSync, which yields across turns). so if the boot is killed mid-migration
67
+ // — the 120s ready-timeout, a DO eviction, an OOM — the _zero.* tables auto-commit
68
+ // but the closing versionHistory INSERT never runs. next boot: getVersionHistory
69
+ // reads an empty table => dataVersion 0 => it re-runs the setup migration =>
70
+ // CREATE TABLE "_zero.replicationConfig" => "already exists" SQLITE_ERROR, and
71
+ // /sync never reaches ready (editor stuck on "loading files"). detect that exact
72
+ // inconsistency (replica data tables present but no versionHistory row) and wipe
73
+ // the _zero.* replica so the embed re-runs initial sync cleanly.
74
+ export function repairPartialReplicaInit(sql, opts = {}) {
75
+ const logPrefix = opts.logPrefix ?? '[orez]';
76
+ const hasConfig = sql
77
+ .exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_zero.replicationConfig'")
78
+ .toArray().length;
79
+ if (!hasConfig)
80
+ return;
81
+ let versionRows = 0;
82
+ try {
83
+ versionRows = sql
84
+ .exec('SELECT 1 FROM "_zero.versionHistory" LIMIT 1')
85
+ .toArray().length;
86
+ }
87
+ catch {
88
+ // versionHistory table missing entirely is also an inconsistent state
89
+ versionRows = 0;
90
+ }
91
+ if (versionRows > 0)
92
+ return;
93
+ // inconsistent: the replica's _zero.* control tables exist but version tracking
94
+ // is empty. the ENTIRE replica is half-initialized — initial-sync creates the
95
+ // _zero.* control tables AND the published app tables in the same interrupted
96
+ // run, so re-running setup also fails on a duplicate app table. drop every
97
+ // replica table so the next boot initial-syncs the whole set from scratch.
98
+ const dropped = dropReplicaTables(sql);
99
+ console.log(logPrefix +
100
+ ' repaired partial replica init: dropped ' +
101
+ dropped +
102
+ ' replica tables (no versionHistory row) so initial sync re-runs');
103
+ }
104
+ // a changeLog transaction group without a commit entry is an interrupted storer
105
+ // write (zero stores each replicated tx inside one pg transaction; real pg rolls
106
+ // a crashed tx back, but the DO sqlite shim auto-commits per turn, so a kill
107
+ // persists the partial group). catchup replays it as begin->data->begin and the
108
+ // replicator dies on "Already in a transaction" on every boot. wiping the replica
109
+ // here makes the uninitialized-replica guard clear cdc state, forcing a clean
110
+ // initial sync.
111
+ export async function resetReplicaIfChangeLogPoisoned(sql, backendExec, opts) {
112
+ const logPrefix = opts.logPrefix ?? '[orez]';
113
+ const initialized = sql
114
+ .exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_zero.replicationConfig'")
115
+ .toArray().length;
116
+ if (!initialized)
117
+ return;
118
+ // /exec parses pg SQL: placeholders are $1-style, never '?'. an {error}
119
+ // response must be surfaced — treating it as "no rows" silently disables the
120
+ // guard (the exact failure mode this guard exists to prevent).
121
+ const listBody = await backendExec("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE $1", [opts.appId + '_%/cdc_changeLog']);
122
+ if (listBody.error) {
123
+ console.error(logPrefix + ' changeLog poison check failed (list): ' + listBody.error);
124
+ return;
125
+ }
126
+ const names = (listBody.rows || []).map((row) => String(row.name));
127
+ for (const name of names) {
128
+ const checkBody = await backendExec('SELECT watermark FROM "' +
129
+ name.replaceAll('"', '""') +
130
+ "\" GROUP BY watermark HAVING SUM(CASE WHEN json_extract(change, '$.tag') = 'commit' THEN 1 ELSE 0 END) = 0 LIMIT 1");
131
+ if (checkBody.error) {
132
+ console.error(logPrefix + ' changeLog poison check failed (scan): ' + checkBody.error);
133
+ return;
134
+ }
135
+ const rows = checkBody.rows || [];
136
+ console.log(logPrefix +
137
+ ' changeLog poison scan: ' +
138
+ name +
139
+ ' -> ' +
140
+ (rows.length ? 'POISONED at ' + rows[0].watermark : 'clean'));
141
+ if (!rows.length)
142
+ continue;
143
+ const dropped = dropReplicaTables(sql);
144
+ console.log(logPrefix +
145
+ ' cdc changeLog has a partial transaction at watermark ' +
146
+ rows[0].watermark +
147
+ ' (interrupted storer write): dropped ' +
148
+ dropped +
149
+ ' replica tables so cdc state clears and initial sync re-runs');
150
+ return;
151
+ }
152
+ }
153
+ // a replica without its init marker must not reuse the cdc subscription state,
154
+ // or initial sync never re-runs. the change-streamer's subscription state lives
155
+ // in the SQL DO and SURVIVES a replica wipe (the resets above, or an OOM
156
+ // eviction); a wiped replica + surviving subscription state makes zero-cache
157
+ // skip initial sync ("already synced") and serve an EMPTY replica that only ever
158
+ // receives catchup changes. when the replica has no init marker, clear the cdc
159
+ // state so the embed re-runs initial sync from scratch.
160
+ export async function clearChangeStreamerStateIfReplicaUninitialized(sql, backendExec, opts) {
161
+ const logPrefix = opts.logPrefix ?? '[orez]';
162
+ const initialized = sql
163
+ .exec("SELECT 1 FROM sqlite_master WHERE type='table' AND name='_zero.replicationConfig'")
164
+ .toArray().length;
165
+ if (initialized)
166
+ return;
167
+ // /exec parses pg SQL: $1-style placeholders, never '?' (a '?' is a parse
168
+ // error whose {error} body silently disabled this guard).
169
+ const body = await backendExec("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE $1", [opts.appId + '_%/cdc_%']);
170
+ if (body.error) {
171
+ console.error(logPrefix + ' cdc state clear failed (list): ' + body.error);
172
+ return;
173
+ }
174
+ const names = (body.rows || []).map((row) => String(row.name));
175
+ for (const name of names) {
176
+ await backendExec('DROP TABLE IF EXISTS "' + name.replaceAll('"', '""') + '"');
177
+ }
178
+ if (names.length) {
179
+ console.log(logPrefix +
180
+ ' replica uninitialized: cleared ' +
181
+ names.length +
182
+ ' cdc state tables so initial sync re-runs');
183
+ }
184
+ }
185
+ //# sourceMappingURL=zero-cache-replica-repair.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-cache-replica-repair.js","sourceRoot":"","sources":["../../src/worker/zero-cache-replica-repair.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * runaway-write circuit breaker for a Durable Object's SQLite storage.
3
+ *
4
+ * a ZeroSqlDO (orez `ZeroDO`) runs the pg-over-DO backend in-instance, so a
5
+ * buggy or malicious mutation flow can write unbounded rows into the DO's
6
+ * SQLite and blow past the platform's per-object storage + billing limits
7
+ * before anything notices. this wraps `sql.exec` to meter rows written per
8
+ * rolling window and trip — refusing further writes — once the rate stays over
9
+ * a soft threshold for a sustained period, or instantly past a hard threshold.
10
+ *
11
+ * the meter state lives in a single-row table in the same SQLite, so a tripped
12
+ * breaker survives DO eviction (the object stays bricked-for-writes until an
13
+ * operator clears the row). reads are never gated. the wrap is idempotent per
14
+ * `sql` handle.
15
+ *
16
+ * pure logic over the minimal `DurableSqlStorage` shape (no `@cloudflare/...`
17
+ * types), unit-tested in zero-sql-write-circuit.test.ts. consumers install it
18
+ * from their ZeroSqlDO constructor with their own table/log prefix.
19
+ */
20
+ export interface DurableSqlCursor {
21
+ one(): Record<string, unknown> | undefined;
22
+ rowsWritten?: number;
23
+ }
24
+ export interface DurableSqlStorage {
25
+ exec(sql: string, ...params: unknown[]): DurableSqlCursor;
26
+ }
27
+ export interface WriteCircuitOptions {
28
+ /** single-row meter table name. default `_orez_write_circuit`. */
29
+ table?: string;
30
+ /** soft cap: rows/window above which the sustained timer starts. default 2,000,000. */
31
+ rowsPerWindow?: number;
32
+ /** hard cap: rows/window that trips instantly. default 10,000,000. */
33
+ hardRowsPerWindow?: number;
34
+ /** rolling window length in ms. default 60,000. */
35
+ windowMs?: number;
36
+ /** how long the rate must stay over the soft cap before tripping, in ms. default 180,000. */
37
+ sustainedMs?: number;
38
+ /** log prefix for the trip diagnostics. default `[orez]`. */
39
+ logPrefix?: string;
40
+ }
41
+ /**
42
+ * wrap `sql.exec` with the runaway-write circuit breaker. idempotent: a second
43
+ * call on the same `sql` handle is a no-op.
44
+ */
45
+ export declare function installZeroSqlWriteCircuitBreaker(sql: DurableSqlStorage, opts?: WriteCircuitOptions): void;
46
+ //# sourceMappingURL=zero-sql-write-circuit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-sql-write-circuit.d.ts","sourceRoot":"","sources":["../../src/worker/zero-sql-write-circuit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,GAAG,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IAC1C,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,gBAAgB,CAAA;CAC1D;AAED,MAAM,WAAW,mBAAmB;IAClC,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,uFAAuF;IACvF,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6FAA6F;IAC7F,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAQD;;;GAGG;AACH,wBAAgB,iCAAiC,CAC/C,GAAG,EAAE,iBAAiB,EACtB,IAAI,GAAE,mBAAwB,GAC7B,IAAI,CA+HN"}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * runaway-write circuit breaker for a Durable Object's SQLite storage.
3
+ *
4
+ * a ZeroSqlDO (orez `ZeroDO`) runs the pg-over-DO backend in-instance, so a
5
+ * buggy or malicious mutation flow can write unbounded rows into the DO's
6
+ * SQLite and blow past the platform's per-object storage + billing limits
7
+ * before anything notices. this wraps `sql.exec` to meter rows written per
8
+ * rolling window and trip — refusing further writes — once the rate stays over
9
+ * a soft threshold for a sustained period, or instantly past a hard threshold.
10
+ *
11
+ * the meter state lives in a single-row table in the same SQLite, so a tripped
12
+ * breaker survives DO eviction (the object stays bricked-for-writes until an
13
+ * operator clears the row). reads are never gated. the wrap is idempotent per
14
+ * `sql` handle.
15
+ *
16
+ * pure logic over the minimal `DurableSqlStorage` shape (no `@cloudflare/...`
17
+ * types), unit-tested in zero-sql-write-circuit.test.ts. consumers install it
18
+ * from their ZeroSqlDO constructor with their own table/log prefix.
19
+ */
20
+ // any statement that can write rows (so reads never pay the meter cost).
21
+ const MUTATION_RE = /^\s*(?:insert|update|delete|replace|create|alter|drop|truncate|vacuum|reindex|with)\b/i;
22
+ const INSTALLED = new WeakSet();
23
+ /**
24
+ * wrap `sql.exec` with the runaway-write circuit breaker. idempotent: a second
25
+ * call on the same `sql` handle is a no-op.
26
+ */
27
+ export function installZeroSqlWriteCircuitBreaker(sql, opts = {}) {
28
+ if (!sql || INSTALLED.has(sql))
29
+ return;
30
+ const table = opts.table ?? '_orez_write_circuit';
31
+ const rowsPerWindow = opts.rowsPerWindow ?? 2_000_000;
32
+ const hardRowsPerWindow = opts.hardRowsPerWindow ?? 10_000_000;
33
+ const windowMs = opts.windowMs ?? 60 * 1000;
34
+ const sustainedMs = opts.sustainedMs ?? 3 * 60 * 1000;
35
+ const logPrefix = opts.logPrefix ?? '[orez]';
36
+ const rawExec = sql.exec.bind(sql);
37
+ let ready = false;
38
+ const ensureReady = () => {
39
+ if (ready)
40
+ return;
41
+ rawExec('CREATE TABLE IF NOT EXISTS ' +
42
+ table +
43
+ ' (id INTEGER PRIMARY KEY CHECK (id = 1), window_start INTEGER NOT NULL DEFAULT 0, rows_in_window INTEGER NOT NULL DEFAULT 0, first_over_at INTEGER NOT NULL DEFAULT 0, tripped_at INTEGER NOT NULL DEFAULT 0, last_statement TEXT)');
44
+ rawExec('INSERT OR IGNORE INTO ' +
45
+ table +
46
+ ' (id, window_start, rows_in_window, first_over_at, tripped_at, last_statement) VALUES (1, 0, 0, 0, 0, ?)', '');
47
+ ready = true;
48
+ };
49
+ const readState = () => {
50
+ ensureReady();
51
+ return (rawExec('SELECT window_start, rows_in_window, first_over_at, tripped_at FROM ' +
52
+ table +
53
+ ' WHERE id = 1').one() || {});
54
+ };
55
+ const clippedStatement = (statement) => String(statement || '')
56
+ .replace(/\s+/g, ' ')
57
+ .trim()
58
+ .slice(0, 500);
59
+ const assertOpen = (statement) => {
60
+ const state = readState();
61
+ const trippedAt = Number(state.tripped_at || 0);
62
+ if (trippedAt > 0) {
63
+ throw new Error(logPrefix +
64
+ ' ZeroSqlDO write circuit breaker tripped at ' +
65
+ new Date(trippedAt).toISOString() +
66
+ '; refusing SQL write: ' +
67
+ clippedStatement(statement));
68
+ }
69
+ };
70
+ const recordRowsWritten = (rowsWritten, statement) => {
71
+ const rows = Number(rowsWritten || 0);
72
+ if (!Number.isFinite(rows) || rows <= 0)
73
+ return false;
74
+ const now = Date.now();
75
+ const state = readState();
76
+ let windowStart = Number(state.window_start || 0);
77
+ let rowsInWindow = Number(state.rows_in_window || 0);
78
+ let firstOverAt = Number(state.first_over_at || 0);
79
+ const trippedAt = Number(state.tripped_at || 0);
80
+ if (trippedAt > 0)
81
+ return true;
82
+ const windowAgeMs = windowStart ? now - windowStart : 0;
83
+ if (!windowStart || windowAgeMs >= windowMs) {
84
+ const previousWindowWasOver = windowAgeMs < windowMs * 2 && rowsInWindow > rowsPerWindow;
85
+ windowStart = now;
86
+ rowsInWindow = 0;
87
+ if (!previousWindowWasOver)
88
+ firstOverAt = 0;
89
+ }
90
+ rowsInWindow += rows;
91
+ let nextTrippedAt = 0;
92
+ if (rowsInWindow > rowsPerWindow) {
93
+ if (!firstOverAt)
94
+ firstOverAt = now;
95
+ if (rowsInWindow > hardRowsPerWindow || now - firstOverAt >= sustainedMs) {
96
+ nextTrippedAt = now;
97
+ }
98
+ }
99
+ rawExec('UPDATE ' +
100
+ table +
101
+ ' SET window_start = ?, rows_in_window = ?, first_over_at = ?, tripped_at = ?, last_statement = ? WHERE id = 1', windowStart, rowsInWindow, firstOverAt, nextTrippedAt, clippedStatement(statement));
102
+ if (nextTrippedAt) {
103
+ console.error(logPrefix +
104
+ ' ZeroSqlDO write circuit breaker tripped: rows_in_window=' +
105
+ rowsInWindow +
106
+ ', rows_written=' +
107
+ rows +
108
+ ', statement=' +
109
+ clippedStatement(statement));
110
+ return true;
111
+ }
112
+ return false;
113
+ };
114
+ sql.exec = (statement, ...params) => {
115
+ const text = String(statement || '');
116
+ const isCircuitStatement = text.includes(table);
117
+ const isMutation = MUTATION_RE.test(text) && !isCircuitStatement;
118
+ if (isMutation)
119
+ assertOpen(text);
120
+ const cursor = rawExec(statement, ...params);
121
+ if (isMutation && recordRowsWritten(cursor && cursor.rowsWritten, text)) {
122
+ assertOpen(text);
123
+ }
124
+ return cursor;
125
+ };
126
+ INSTALLED.add(sql);
127
+ }
128
+ //# sourceMappingURL=zero-sql-write-circuit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zero-sql-write-circuit.js","sourceRoot":"","sources":["../../src/worker/zero-sql-write-circuit.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orez",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "PGlite-powered zero-sync development backend. No Docker required.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -87,7 +87,7 @@
87
87
  "@electric-sql/pglite": "0.4.1",
88
88
  "@electric-sql/pglite-tools": "^0.3.1",
89
89
  "@pgsql/traverse": "17.2.6",
90
- "bedrock-sqlite": "0.4.24",
90
+ "bedrock-sqlite": "0.4.26",
91
91
  "citty": "^0.2.0",
92
92
  "pg-gateway": "0.3.0-beta.4",
93
93
  "pgsql-parser": "^17.9.11",