orez-lite 0.9.7
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 +12 -0
- package/dist/browser/generated/sqlite3-browser.wasm +0 -0
- package/dist/browser/generated/sync_wasm.js +922 -0
- package/dist/browser/generated/sync_wasm_bg.wasm +0 -0
- package/dist/browser/host.d.ts +10 -0
- package/dist/browser/host.d.ts.map +1 -0
- package/dist/browser/host.js +496 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/idb-snapshot.d.ts +11 -0
- package/dist/browser/idb-snapshot.d.ts.map +1 -0
- package/dist/browser/idb-snapshot.js +122 -0
- package/dist/browser/idb-snapshot.js.map +1 -0
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-port.d.ts +7 -0
- package/dist/browser/message-port.d.ts.map +1 -0
- package/dist/browser/message-port.js +188 -0
- package/dist/browser/message-port.js.map +1 -0
- package/dist/browser/sqlite-adapter.d.ts +53 -0
- package/dist/browser/sqlite-adapter.d.ts.map +1 -0
- package/dist/browser/sqlite-adapter.js +126 -0
- package/dist/browser/sqlite-adapter.js.map +1 -0
- package/dist/browser/types.d.ts +52 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cf-deploy/bundle.d.ts +18 -0
- package/dist/cf-deploy/bundle.d.ts.map +1 -0
- package/dist/cf-deploy/bundle.js +273 -0
- package/dist/cf-deploy/bundle.js.map +1 -0
- package/dist/cf-deploy/config.d.ts +12 -0
- package/dist/cf-deploy/config.d.ts.map +1 -0
- package/dist/cf-deploy/config.js +11 -0
- package/dist/cf-deploy/config.js.map +1 -0
- package/dist/cf-deploy/index.d.ts +10 -0
- package/dist/cf-deploy/index.d.ts.map +1 -0
- package/dist/cf-deploy/index.js +12 -0
- package/dist/cf-deploy/index.js.map +1 -0
- package/dist/cf-deploy/leaves.d.ts +8 -0
- package/dist/cf-deploy/leaves.d.ts.map +1 -0
- package/dist/cf-deploy/leaves.js +66 -0
- package/dist/cf-deploy/leaves.js.map +1 -0
- package/dist/cf-deploy/migration.d.ts +27 -0
- package/dist/cf-deploy/migration.d.ts.map +1 -0
- package/dist/cf-deploy/migration.js +734 -0
- package/dist/cf-deploy/migration.js.map +1 -0
- package/dist/cf-deploy/nativeMigrations.d.ts +24 -0
- package/dist/cf-deploy/nativeMigrations.d.ts.map +1 -0
- package/dist/cf-deploy/nativeMigrations.js +185 -0
- package/dist/cf-deploy/nativeMigrations.js.map +1 -0
- package/dist/cf-deploy/prune.d.ts +14 -0
- package/dist/cf-deploy/prune.d.ts.map +1 -0
- package/dist/cf-deploy/prune.js +235 -0
- package/dist/cf-deploy/prune.js.map +1 -0
- package/dist/cf-deploy/readiness.d.ts +11 -0
- package/dist/cf-deploy/readiness.d.ts.map +1 -0
- package/dist/cf-deploy/readiness.js +56 -0
- package/dist/cf-deploy/readiness.js.map +1 -0
- package/dist/cf-deploy/sources.d.ts +3 -0
- package/dist/cf-deploy/sources.d.ts.map +1 -0
- package/dist/cf-deploy/sources.js +24 -0
- package/dist/cf-deploy/sources.js.map +1 -0
- package/dist/cf-deploy/wrangler.d.ts +9 -0
- package/dist/cf-deploy/wrangler.d.ts.map +1 -0
- package/dist/cf-deploy/wrangler.js +48 -0
- package/dist/cf-deploy/wrangler.js.map +1 -0
- package/dist/cf-do/application-sql.d.ts +49 -0
- package/dist/cf-do/application-sql.d.ts.map +1 -0
- package/dist/cf-do/application-sql.js +124 -0
- package/dist/cf-do/application-sql.js.map +1 -0
- package/dist/cf-do/cdc.d.ts +155 -0
- package/dist/cf-do/cdc.d.ts.map +1 -0
- package/dist/cf-do/cdc.js +723 -0
- package/dist/cf-do/cdc.js.map +1 -0
- package/dist/cf-do/cursor-pull.d.ts +27 -0
- package/dist/cf-do/cursor-pull.d.ts.map +1 -0
- package/dist/cf-do/cursor-pull.js +78 -0
- package/dist/cf-do/cursor-pull.js.map +1 -0
- package/dist/cf-do/lite-data-worker.d.ts +171 -0
- package/dist/cf-do/lite-data-worker.d.ts.map +1 -0
- package/dist/cf-do/lite-data-worker.js +685 -0
- package/dist/cf-do/lite-data-worker.js.map +1 -0
- package/dist/cf-do/namespace-backup.d.ts +85 -0
- package/dist/cf-do/namespace-backup.d.ts.map +1 -0
- package/dist/cf-do/namespace-backup.js +470 -0
- package/dist/cf-do/namespace-backup.js.map +1 -0
- package/dist/cf-do/row-undo.d.ts +36 -0
- package/dist/cf-do/row-undo.d.ts.map +1 -0
- package/dist/cf-do/row-undo.js +221 -0
- package/dist/cf-do/row-undo.js.map +1 -0
- package/dist/cf-do/tx-journal.d.ts +118 -0
- package/dist/cf-do/tx-journal.d.ts.map +1 -0
- package/dist/cf-do/tx-journal.js +717 -0
- package/dist/cf-do/tx-journal.js.map +1 -0
- package/dist/cf-do/watermark.d.ts +29 -0
- package/dist/cf-do/watermark.d.ts.map +1 -0
- package/dist/cf-do/watermark.js +105 -0
- package/dist/cf-do/watermark.js.map +1 -0
- package/dist/cf-do/worker.d.ts +282 -0
- package/dist/cf-do/worker.d.ts.map +1 -0
- package/dist/cf-do/worker.js +1999 -0
- package/dist/cf-do/worker.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/cloudflare-build.d.ts +9 -0
- package/dist/cloudflare-build.d.ts.map +1 -0
- package/dist/cloudflare-build.js +9 -0
- package/dist/cloudflare-build.js.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.js +2 -0
- package/dist/cloudflare-bun-wasm-loader.js.map +1 -0
- package/dist/cloudflare-query-compiler.d.ts +2 -0
- package/dist/cloudflare-query-compiler.d.ts.map +1 -0
- package/dist/cloudflare-query-compiler.js +2 -0
- package/dist/cloudflare-query-compiler.js.map +1 -0
- package/dist/cloudflare-runtime.d.ts +15 -0
- package/dist/cloudflare-runtime.d.ts.map +1 -0
- package/dist/cloudflare-runtime.js +11 -0
- package/dist/cloudflare-runtime.js.map +1 -0
- package/dist/cloudflare-sync.d.ts +3 -0
- package/dist/cloudflare-sync.d.ts.map +1 -0
- package/dist/cloudflare-sync.js +3 -0
- package/dist/cloudflare-sync.js.map +1 -0
- package/dist/cloudflare-transaction-query.d.ts +2 -0
- package/dist/cloudflare-transaction-query.d.ts.map +1 -0
- package/dist/cloudflare-transaction-query.js +2 -0
- package/dist/cloudflare-transaction-query.js.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.js +2 -0
- package/dist/cloudflare-vite-wasm-loader.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +3 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/do-sql-tracking.d.ts +114 -0
- package/dist/do-sql-tracking.d.ts.map +1 -0
- package/dist/do-sql-tracking.js +280 -0
- package/dist/do-sql-tracking.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/wake.d.ts +22 -0
- package/dist/wake.d.ts.map +1 -0
- package/dist/wake.js +114 -0
- package/dist/wake.js.map +1 -0
- package/dist/worker/cf-do-shim.d.ts +53 -0
- package/dist/worker/cf-do-shim.d.ts.map +1 -0
- package/dist/worker/cf-do-shim.js +66 -0
- package/dist/worker/cf-do-shim.js.map +1 -0
- package/dist/zero-http/encrypted-column-codec.d.ts +46 -0
- package/dist/zero-http/encrypted-column-codec.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-codec.js +676 -0
- package/dist/zero-http/encrypted-column-codec.js.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts +7 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.js +161 -0
- package/dist/zero-http/encrypted-column-conformance.js.map +1 -0
- package/dist/zero-http/fixture-schema.d.ts +127 -0
- package/dist/zero-http/fixture-schema.d.ts.map +1 -0
- package/dist/zero-http/fixture-schema.js +56 -0
- package/dist/zero-http/fixture-schema.js.map +1 -0
- package/dist/zero-http/mount.d.ts +71 -0
- package/dist/zero-http/mount.d.ts.map +1 -0
- package/dist/zero-http/mount.js +594 -0
- package/dist/zero-http/mount.js.map +1 -0
- package/dist/zero-http/payload-codec.d.ts +91 -0
- package/dist/zero-http/payload-codec.d.ts.map +1 -0
- package/dist/zero-http/payload-codec.js +10 -0
- package/dist/zero-http/payload-codec.js.map +1 -0
- package/dist/zero-http/server.d.ts +16 -0
- package/dist/zero-http/server.d.ts.map +1 -0
- package/dist/zero-http/server.js +270 -0
- package/dist/zero-http/server.js.map +1 -0
- package/dist/zero-http/test-harness.d.ts +32 -0
- package/dist/zero-http/test-harness.d.ts.map +1 -0
- package/dist/zero-http/test-harness.js +77 -0
- package/dist/zero-http/test-harness.js.map +1 -0
- package/dist/zero-http/transport.d.ts +71 -0
- package/dist/zero-http/transport.d.ts.map +1 -0
- package/dist/zero-http/transport.js +1106 -0
- package/dist/zero-http/transport.js.map +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* durable transaction journal for the DO SQL backend.
|
|
3
|
+
*
|
|
4
|
+
* Durable Object RPC transactions apply writes eagerly. Parsed DML is rolled back from the
|
|
5
|
+
* CDC row before-images captured by the same SQLite statement; unknown writes
|
|
6
|
+
* fall back to a first-write table snapshot (`_orez_tx_<txID>_*`). Before this
|
|
7
|
+
* journal, the rollback bookkeeping lived only in the client's memory: a DO eviction or
|
|
8
|
+
* deploy upgrade-kill mid-transaction persisted the partial writes forever
|
|
9
|
+
* (the 2026-06 poisoned cdc changeLog incident — zero's catchup replays
|
|
10
|
+
* begin→data→begin and the replicator wedges permanently).
|
|
11
|
+
*
|
|
12
|
+
* the journal makes the snapshot bookkeeping durable and the commit point
|
|
13
|
+
* atomic:
|
|
14
|
+
*
|
|
15
|
+
* - every row-journal marker or fallback snapshot is recorded in
|
|
16
|
+
* `_orez_tx_manifest` atomically with its rollback state.
|
|
17
|
+
* - COMMIT = one atomic storage transaction that drops rollback state and
|
|
18
|
+
* deletes the manifest rows (plus promoting pending tracked changes in
|
|
19
|
+
* ZeroDO). a tx is committed if and only if its manifest rows are gone.
|
|
20
|
+
* - ROLLBACK = one atomic storage transaction that restores row before-images
|
|
21
|
+
* plus any fallback table snapshots (in reverse order).
|
|
22
|
+
* - RECOVERY (`recoverTxJournal`) rolls back every manifest tx for an
|
|
23
|
+
* owner whose process generation is known dead (e.g. the zero-cache
|
|
24
|
+
* embed at boot, before it opens any pg session), so a partial tx is
|
|
25
|
+
* invisible on the next boot.
|
|
26
|
+
*
|
|
27
|
+
* all functions are synchronous over a minimal sql-exec interface so the
|
|
28
|
+
* same core runs inside ZeroDO's ctx.storage.transaction() and inside the
|
|
29
|
+
* embed-local backend's transactionSync().
|
|
30
|
+
*/
|
|
31
|
+
import { restoreTriggers, suspendTriggers, writableColumns } from './cdc.js';
|
|
32
|
+
export const TX_MANIFEST_TABLE = '_orez_tx_manifest';
|
|
33
|
+
export const TX_SCHEMA_TABLE = '_orez_tx_schema';
|
|
34
|
+
export const TX_MANIFEST_DDL = `CREATE TABLE IF NOT EXISTS "${TX_MANIFEST_TABLE}" (` +
|
|
35
|
+
'seq INTEGER PRIMARY KEY AUTOINCREMENT, ' +
|
|
36
|
+
'tx_id TEXT NOT NULL, ' +
|
|
37
|
+
"owner TEXT NOT NULL DEFAULT 'default', " +
|
|
38
|
+
'original TEXT NOT NULL, ' +
|
|
39
|
+
'snapshot TEXT)';
|
|
40
|
+
export const TX_SCHEMA_DDL = `CREATE TABLE IF NOT EXISTS "${TX_SCHEMA_TABLE}" (` +
|
|
41
|
+
'seq INTEGER PRIMARY KEY AUTOINCREMENT, ' +
|
|
42
|
+
'tx_id TEXT NOT NULL, ' +
|
|
43
|
+
"owner TEXT NOT NULL DEFAULT 'default', " +
|
|
44
|
+
'type TEXT NOT NULL, ' +
|
|
45
|
+
'name TEXT NOT NULL, ' +
|
|
46
|
+
'tbl_name TEXT NOT NULL, ' +
|
|
47
|
+
'sql TEXT)';
|
|
48
|
+
/**
|
|
49
|
+
* The cdc registration table. It stays in INTERNAL_TABLES -- ordinary row-level
|
|
50
|
+
* undo must not touch it -- and the schema restore carves it out explicitly
|
|
51
|
+
* instead; see `snapshotCdcRegistration`.
|
|
52
|
+
*/
|
|
53
|
+
const CDC_REGISTRATION_TABLE = '_orez_cdc_tables';
|
|
54
|
+
const INTERNAL_TABLES = new Set([
|
|
55
|
+
TX_MANIFEST_TABLE,
|
|
56
|
+
TX_SCHEMA_TABLE,
|
|
57
|
+
CDC_REGISTRATION_TABLE,
|
|
58
|
+
'_orez_cdc_buffer',
|
|
59
|
+
'_zero_pending_changes',
|
|
60
|
+
'_zero_changes',
|
|
61
|
+
'_zero_change_state',
|
|
62
|
+
'_zero_schema_tables',
|
|
63
|
+
]);
|
|
64
|
+
function isInternalObject(name) {
|
|
65
|
+
return (INTERNAL_TABLES.has(name) ||
|
|
66
|
+
name.startsWith('_orez_tx_') ||
|
|
67
|
+
name.startsWith('sqlite_'));
|
|
68
|
+
}
|
|
69
|
+
function quoteIdent(name) {
|
|
70
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
71
|
+
}
|
|
72
|
+
function manifestTableExists(sql) {
|
|
73
|
+
return (sql
|
|
74
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?", TX_MANIFEST_TABLE)
|
|
75
|
+
.toArray().length > 0);
|
|
76
|
+
}
|
|
77
|
+
function tableExists(sql, name) {
|
|
78
|
+
return (sql
|
|
79
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?", name)
|
|
80
|
+
.toArray().length > 0);
|
|
81
|
+
}
|
|
82
|
+
function schemaTableExists(sql) {
|
|
83
|
+
return (sql
|
|
84
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?", TX_SCHEMA_TABLE)
|
|
85
|
+
.toArray().length > 0);
|
|
86
|
+
}
|
|
87
|
+
function schemaRows(sql, txID) {
|
|
88
|
+
if (!schemaTableExists(sql))
|
|
89
|
+
return [];
|
|
90
|
+
return sql
|
|
91
|
+
.exec(`SELECT seq, type, name, tbl_name, sql FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ? ORDER BY seq`, txID)
|
|
92
|
+
.toArray()
|
|
93
|
+
.map((row) => ({
|
|
94
|
+
seq: Number(row.seq),
|
|
95
|
+
type: String(row.type),
|
|
96
|
+
name: String(row.name),
|
|
97
|
+
table: String(row.tbl_name),
|
|
98
|
+
sql: row.sql === null || row.sql === undefined ? null : String(row.sql),
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
function manifestRows(sql, txID) {
|
|
102
|
+
return sql
|
|
103
|
+
.exec(`SELECT seq, tx_id, original, snapshot FROM "${TX_MANIFEST_TABLE}" WHERE tx_id = ? ORDER BY seq`, txID)
|
|
104
|
+
.toArray()
|
|
105
|
+
.map((row) => ({
|
|
106
|
+
seq: Number(row.seq),
|
|
107
|
+
txId: String(row.tx_id),
|
|
108
|
+
original: String(row.original),
|
|
109
|
+
snapshot: row.snapshot === null || row.snapshot === undefined ? null : String(row.snapshot),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
function dropTable(sql, table) {
|
|
113
|
+
sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(table)}`);
|
|
114
|
+
}
|
|
115
|
+
/** Parent snapshots must restore before children so FK cascades cannot erase a restored child. */
|
|
116
|
+
function parentFirst(sql, rows) {
|
|
117
|
+
const byTable = new Map(rows.map((row) => [row.original, row]));
|
|
118
|
+
const edges = new Map(rows.map((row) => [row.original, new Set()]));
|
|
119
|
+
const incoming = new Map(rows.map((row) => [row.original, 0]));
|
|
120
|
+
for (const child of rows) {
|
|
121
|
+
const parents = sql
|
|
122
|
+
.exec(`PRAGMA foreign_key_list(${quoteIdent(child.original)})`)
|
|
123
|
+
.toArray()
|
|
124
|
+
.map((row) => String(row.table ?? ''));
|
|
125
|
+
for (const parent of new Set(parents)) {
|
|
126
|
+
if (!byTable.has(parent) || parent === child.original)
|
|
127
|
+
continue;
|
|
128
|
+
const children = edges.get(parent);
|
|
129
|
+
if (children.has(child.original))
|
|
130
|
+
continue;
|
|
131
|
+
children.add(child.original);
|
|
132
|
+
incoming.set(child.original, (incoming.get(child.original) ?? 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const ready = rows.filter((row) => incoming.get(row.original) === 0);
|
|
136
|
+
const ordered = [];
|
|
137
|
+
while (ready.length > 0) {
|
|
138
|
+
const row = ready.shift();
|
|
139
|
+
ordered.push(row);
|
|
140
|
+
for (const child of edges.get(row.original) ?? []) {
|
|
141
|
+
const count = (incoming.get(child) ?? 0) - 1;
|
|
142
|
+
incoming.set(child, count);
|
|
143
|
+
if (count === 0)
|
|
144
|
+
ready.push(byTable.get(child));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Cyclic foreign keys are checked at the surrounding transaction boundary;
|
|
148
|
+
// retain deterministic manifest order for the strongly connected remainder.
|
|
149
|
+
for (const row of rows)
|
|
150
|
+
if (!ordered.includes(row))
|
|
151
|
+
ordered.push(row);
|
|
152
|
+
return ordered;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* true when restoreSchemaSnapshot will rebuild `table` during the rollback of
|
|
156
|
+
* `txID`: the tx recorded a transactional-DDL schema marker and the table was
|
|
157
|
+
* created, dropped, or DDL-changed since the schema snapshot. row-level undo
|
|
158
|
+
* consults this to skip such tables — their pre-tx content comes back
|
|
159
|
+
* wholesale from the table snapshot, and undoing rows against a dropped or
|
|
160
|
+
* rebuilt table either throws or targets the wrong shape. before this,
|
|
161
|
+
* recovery of a tx killed mid table rebuild threw on every boot and wedged
|
|
162
|
+
* the namespace permanently (2026-07-24 prod fresh-project incident).
|
|
163
|
+
*/
|
|
164
|
+
export function schemaRestoreOwnsTable(sql, txID, table) {
|
|
165
|
+
if (!schemaTableExists(sql))
|
|
166
|
+
return false;
|
|
167
|
+
const rows = sql
|
|
168
|
+
.exec(`SELECT type, name, sql FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ? AND type IN ('marker', 'table')`, txID)
|
|
169
|
+
.toArray();
|
|
170
|
+
if (!rows.some((row) => String(row.type) === 'marker'))
|
|
171
|
+
return false;
|
|
172
|
+
const original = rows.find((row) => String(row.type) === 'table' && String(row.name) === table);
|
|
173
|
+
const current = sql
|
|
174
|
+
.exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ? AND sql IS NOT NULL`, table)
|
|
175
|
+
.toArray()[0];
|
|
176
|
+
if (!original || !current)
|
|
177
|
+
return true;
|
|
178
|
+
return String(original.sql ?? '') !== String(current.sql ?? '');
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Capture the application schema before the first DDL statement and the data
|
|
182
|
+
* of each table that a later DDL statement can destructively change. SQLite
|
|
183
|
+
* can transact DDL, but the DO protocol spans requests, so rollback needs an
|
|
184
|
+
* explicit durable image. The caller must wrap this in one storage transaction.
|
|
185
|
+
*/
|
|
186
|
+
export function snapshotTxSchema(sql, txID, owner = 'default', affectedTables = []) {
|
|
187
|
+
sql.exec(TX_SCHEMA_DDL);
|
|
188
|
+
const exists = sql
|
|
189
|
+
.exec(`SELECT 1 AS ok FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ? AND type = 'marker' LIMIT 1`, txID)
|
|
190
|
+
.toArray();
|
|
191
|
+
if (exists.length === 0) {
|
|
192
|
+
const objects = sql
|
|
193
|
+
.exec('SELECT type, name, tbl_name, sql FROM sqlite_master ' +
|
|
194
|
+
"WHERE type IN ('table', 'index', 'trigger', 'view') AND sql IS NOT NULL " +
|
|
195
|
+
'ORDER BY rowid')
|
|
196
|
+
.toArray()
|
|
197
|
+
.map((row) => ({
|
|
198
|
+
type: String(row.type ?? ''),
|
|
199
|
+
name: String(row.name ?? ''),
|
|
200
|
+
table: String(row.tbl_name ?? ''),
|
|
201
|
+
sql: String(row.sql ?? ''),
|
|
202
|
+
}))
|
|
203
|
+
.filter((row) => row.name && !isInternalObject(row.name));
|
|
204
|
+
// A marker makes even an empty pre-transaction schema recoverable after a
|
|
205
|
+
// kill between CREATE TABLE and the client COMMIT/ROLLBACK.
|
|
206
|
+
sql.exec(`INSERT INTO "${TX_SCHEMA_TABLE}" (tx_id, owner, type, name, tbl_name, sql) VALUES (?, ?, 'marker', '', '', NULL)`, txID, owner);
|
|
207
|
+
for (const object of objects) {
|
|
208
|
+
sql.exec(`INSERT INTO "${TX_SCHEMA_TABLE}" (tx_id, owner, type, name, tbl_name, sql) VALUES (?, ?, ?, ?, ?, ?)`, txID, owner, object.type, object.name, object.table, object.sql);
|
|
209
|
+
}
|
|
210
|
+
snapshotCdcRegistrations(sql, txID, owner);
|
|
211
|
+
}
|
|
212
|
+
for (const table of new Set(affectedTables)) {
|
|
213
|
+
if (!table || isInternalObject(table))
|
|
214
|
+
continue;
|
|
215
|
+
const tableExists = sql
|
|
216
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", table)
|
|
217
|
+
.toArray();
|
|
218
|
+
if (tableExists.length > 0)
|
|
219
|
+
upgradeToTableSnapshot(sql, txID, table, owner);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Capture every cdc registration alongside the schema snapshot.
|
|
224
|
+
*
|
|
225
|
+
* A cdc trigger is restored by the schema snapshot -- it is not an internal
|
|
226
|
+
* object -- but the row in `_orez_cdc_tables` describing it IS internal and was
|
|
227
|
+
* never restored. The two halves reverted on different rules, so a rollback
|
|
228
|
+
* could leave a trigger with no registration. beginSchemaChange skips
|
|
229
|
+
* unregistered tables, so capture was never suspended and the next
|
|
230
|
+
* DROP/RENAME COLUMN on that table failed on the surviving trigger, forever
|
|
231
|
+
* (production, 2026-07-25).
|
|
232
|
+
*
|
|
233
|
+
* Scope matches the schema snapshot exactly, and that is the whole point. The
|
|
234
|
+
* snapshot is taken over ALL of sqlite_master, not over the statement's
|
|
235
|
+
* affected tables -- only the row-data snapshots are per-table -- so the
|
|
236
|
+
* restore drops any trigger absent from it, including one installed mid-session
|
|
237
|
+
* for a table this transaction never touched. Capturing per affected table
|
|
238
|
+
* instead would leave that table's row behind with its triggers gone: capture
|
|
239
|
+
* silently off on a table the system believes is captured. Registration
|
|
240
|
+
* therefore reverts wholesale, exactly as the triggers it describes do.
|
|
241
|
+
*
|
|
242
|
+
* Deliberately NOT extended to `_orez_cdc_buffer`, its sibling in
|
|
243
|
+
* INTERNAL_TABLES. The buffer is drained and cleared by every statement (see
|
|
244
|
+
* `drain`), so it holds in-flight changes rather than state describing a table;
|
|
245
|
+
* restoring it would re-emit change records for writes the rollback just undid.
|
|
246
|
+
*/
|
|
247
|
+
function snapshotCdcRegistrations(sql, txID, owner) {
|
|
248
|
+
const registrationTableExists = sql
|
|
249
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", CDC_REGISTRATION_TABLE)
|
|
250
|
+
.toArray();
|
|
251
|
+
if (registrationTableExists.length === 0)
|
|
252
|
+
return;
|
|
253
|
+
const rows = sql
|
|
254
|
+
.exec(`SELECT physical_table, table_name, columns_json, publish, schema_version ` +
|
|
255
|
+
`FROM ${quoteIdent(CDC_REGISTRATION_TABLE)}`)
|
|
256
|
+
.toArray();
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
sql.exec(`INSERT INTO "${TX_SCHEMA_TABLE}" (tx_id, owner, type, name, tbl_name, sql) VALUES (?, ?, 'cdc', ?, ?, ?)`, txID, owner, String(row.physical_table ?? ''), String(row.physical_table ?? ''), JSON.stringify(row));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** persist one cheap recovery marker only when a transaction first mutates data. */
|
|
262
|
+
export function beginTxJournal(sql, txID, owner = 'default') {
|
|
263
|
+
sql.exec(TX_SCHEMA_DDL);
|
|
264
|
+
sql.exec(`INSERT INTO "${TX_SCHEMA_TABLE}" (tx_id, owner, type, name, tbl_name, sql) ` +
|
|
265
|
+
`SELECT ?, ?, 'active', '', '', NULL WHERE NOT EXISTS (` +
|
|
266
|
+
`SELECT 1 FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ? AND type = 'active')`, txID, owner, txID);
|
|
267
|
+
}
|
|
268
|
+
function restoreSchemaSnapshot(sql, txID, schema, manifest) {
|
|
269
|
+
const current = sql
|
|
270
|
+
.exec('SELECT type, name, tbl_name, sql FROM sqlite_master ' +
|
|
271
|
+
"WHERE type IN ('table', 'index', 'trigger', 'view') AND sql IS NOT NULL " +
|
|
272
|
+
'ORDER BY rowid DESC')
|
|
273
|
+
.toArray()
|
|
274
|
+
.map((row) => ({
|
|
275
|
+
type: String(row.type ?? ''),
|
|
276
|
+
name: String(row.name ?? ''),
|
|
277
|
+
table: String(row.tbl_name ?? ''),
|
|
278
|
+
sql: String(row.sql ?? ''),
|
|
279
|
+
}))
|
|
280
|
+
.filter((row) => row.name && !isInternalObject(row.name));
|
|
281
|
+
const original = schema.filter((row) => row.type !== 'marker' && row.sql);
|
|
282
|
+
const key = (row) => `${row.type}\0${row.name}`;
|
|
283
|
+
const originalByKey = new Map(original.map((row) => [key(row), row]));
|
|
284
|
+
const currentByKey = new Map(current.map((row) => [key(row), row]));
|
|
285
|
+
const originalTables = original.filter((row) => row.type === 'table');
|
|
286
|
+
const currentTables = current.filter((row) => row.type === 'table');
|
|
287
|
+
const changedOriginalTables = new Set(originalTables
|
|
288
|
+
.filter((row) => currentByKey.get(key(row))?.sql !== row.sql)
|
|
289
|
+
.map((row) => row.name));
|
|
290
|
+
const createdTables = new Set(currentTables.filter((row) => !originalByKey.has(key(row))).map((row) => row.name));
|
|
291
|
+
const tablesToDrop = new Set([...changedOriginalTables, ...createdTables]);
|
|
292
|
+
const snapshotByTable = new Map(manifest
|
|
293
|
+
.filter((row) => row.snapshot)
|
|
294
|
+
.map((row) => [row.original, row.snapshot]));
|
|
295
|
+
for (const table of changedOriginalTables) {
|
|
296
|
+
if (!snapshotByTable.has(table)) {
|
|
297
|
+
throw new Error(`transactional DDL rollback is missing table snapshot: ${table}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// consumed on purpose: workerd executes a statement only as its cursor is
|
|
301
|
+
// read, so a discarded PRAGMA cursor silently never runs.
|
|
302
|
+
sql.exec('PRAGMA defer_foreign_keys = ON').toArray();
|
|
303
|
+
const changedCurrentObject = (object) => {
|
|
304
|
+
const before = originalByKey.get(key(object));
|
|
305
|
+
return !before || before.sql !== object.sql || tablesToDrop.has(object.table);
|
|
306
|
+
};
|
|
307
|
+
// Views can depend on tables. Indexes and triggers disappear with their
|
|
308
|
+
// table, while changed standalone objects are explicitly removed.
|
|
309
|
+
for (const object of current.filter((object) => object.type === 'view' && changedCurrentObject(object))) {
|
|
310
|
+
sql.exec(`DROP VIEW IF EXISTS ${quoteIdent(object.name)}`);
|
|
311
|
+
}
|
|
312
|
+
for (const object of current.filter((object) => object.type === 'trigger' &&
|
|
313
|
+
!tablesToDrop.has(object.table) &&
|
|
314
|
+
changedCurrentObject(object))) {
|
|
315
|
+
sql.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(object.name)}`);
|
|
316
|
+
}
|
|
317
|
+
for (const object of current.filter((object) => object.type === 'index' &&
|
|
318
|
+
!tablesToDrop.has(object.table) &&
|
|
319
|
+
changedCurrentObject(object))) {
|
|
320
|
+
sql.exec(`DROP INDEX IF EXISTS ${quoteIdent(object.name)}`);
|
|
321
|
+
}
|
|
322
|
+
for (const table of tablesToDrop)
|
|
323
|
+
sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(table)}`);
|
|
324
|
+
const tables = originalTables.filter((row) => changedOriginalTables.has(row.name));
|
|
325
|
+
for (const row of tables)
|
|
326
|
+
sql.exec(row.sql);
|
|
327
|
+
for (const row of tables) {
|
|
328
|
+
const snapshot = snapshotByTable.get(row.name);
|
|
329
|
+
const hasRows = sql.exec(`SELECT 1 AS ok FROM ${quoteIdent(snapshot)} LIMIT 1`).toArray().length > 0;
|
|
330
|
+
if (!hasRows)
|
|
331
|
+
continue;
|
|
332
|
+
const columns = writableColumns(sql, row.name);
|
|
333
|
+
if (columns.length > 0) {
|
|
334
|
+
const columnList = columns.map(quoteIdent).join(', ');
|
|
335
|
+
sql.exec(`INSERT INTO ${quoteIdent(row.name)} (${columnList}) SELECT ${columnList} FROM ${quoteIdent(snapshot)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Restore secondary objects only after all table rows are back. This keeps
|
|
339
|
+
// business triggers from firing while the snapshot data is inserted.
|
|
340
|
+
for (const type of ['index', 'trigger', 'view']) {
|
|
341
|
+
for (const row of original.filter((item) => item.type === type)) {
|
|
342
|
+
const after = currentByKey.get(key(row));
|
|
343
|
+
if (!after || after.sql !== row.sql || changedOriginalTables.has(row.table)) {
|
|
344
|
+
sql.exec(row.sql);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Revert cdc registration in lockstep with the triggers the restore above
|
|
349
|
+
// just put back or dropped, at the same whole-database scope the schema
|
|
350
|
+
// snapshot uses. Clearing first is what handles the other direction: a table
|
|
351
|
+
// registered during the transaction has had its triggers dropped by the
|
|
352
|
+
// restore, and a row left behind would claim a captured table that nothing
|
|
353
|
+
// captures. See `snapshotCdcRegistrations`.
|
|
354
|
+
if (sql
|
|
355
|
+
.exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", CDC_REGISTRATION_TABLE)
|
|
356
|
+
.toArray().length > 0) {
|
|
357
|
+
sql.exec(`DELETE FROM ${quoteIdent(CDC_REGISTRATION_TABLE)}`);
|
|
358
|
+
for (const row of schema.filter((item) => item.type === 'cdc' && item.sql)) {
|
|
359
|
+
const registration = JSON.parse(row.sql);
|
|
360
|
+
sql.exec(`INSERT INTO ${quoteIdent(CDC_REGISTRATION_TABLE)} ` +
|
|
361
|
+
'(physical_table, table_name, columns_json, publish, schema_version) VALUES (?, ?, ?, ?, ?)', registration.physical_table, registration.table_name, registration.columns_json, registration.publish, registration.schema_version);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Consume only the snapshots used for schema restoration. Any remaining
|
|
365
|
+
// manifest entries belong to ordinary DML in the same transaction and must
|
|
366
|
+
// still run through the row/table rollback path below.
|
|
367
|
+
if (manifestTableExists(sql)) {
|
|
368
|
+
for (const table of changedOriginalTables) {
|
|
369
|
+
const snapshot = snapshotByTable.get(table);
|
|
370
|
+
if (snapshot)
|
|
371
|
+
dropTable(sql, snapshot);
|
|
372
|
+
sql.exec(`DELETE FROM "${TX_MANIFEST_TABLE}" WHERE tx_id = ? AND original = ?`, txID, table);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
sql.exec(`DELETE FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ?`, txID);
|
|
376
|
+
rollbackTxJournal(sql, txID);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* commit a journaled transaction: drop its snapshot tables and delete its
|
|
380
|
+
* manifest rows. the data writes were applied eagerly during the tx, so once
|
|
381
|
+
* the manifest rows are gone the tx is durably committed. must run inside an
|
|
382
|
+
* atomic storage transaction.
|
|
383
|
+
*/
|
|
384
|
+
export function commitTxJournal(sql, txID) {
|
|
385
|
+
if (manifestTableExists(sql)) {
|
|
386
|
+
for (const row of manifestRows(sql, txID)) {
|
|
387
|
+
if (row.snapshot)
|
|
388
|
+
dropTable(sql, row.snapshot);
|
|
389
|
+
}
|
|
390
|
+
sql.exec(`DELETE FROM "${TX_MANIFEST_TABLE}" WHERE tx_id = ?`, txID);
|
|
391
|
+
}
|
|
392
|
+
if (schemaTableExists(sql)) {
|
|
393
|
+
sql.exec(`DELETE FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ?`, txID);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* roll back a journaled transaction: restore every snapshotted table to its
|
|
398
|
+
* pre-tx contents (parents before children), drop tables that did not exist at
|
|
399
|
+
* first write (null snapshot), then clean up. triggers on restored tables are
|
|
400
|
+
* detached during the restore and re-created after, so restore DML doesn't
|
|
401
|
+
* re-fire change tracking. must run inside an atomic storage transaction.
|
|
402
|
+
*/
|
|
403
|
+
export function rollbackTxJournal(sql, txID) {
|
|
404
|
+
const schema = schemaRows(sql, txID);
|
|
405
|
+
const rows = manifestTableExists(sql) ? manifestRows(sql, txID).reverse() : [];
|
|
406
|
+
if (schema.some((row) => row.type === 'marker')) {
|
|
407
|
+
restoreSchemaSnapshot(sql, txID, schema, rows);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (rows.length === 0) {
|
|
411
|
+
if (schema.length > 0) {
|
|
412
|
+
sql.exec(`DELETE FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ?`, txID);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// A snapshotted table can be gone by the time its row rollback runs: a schema
|
|
417
|
+
// restore in the same recovery drops created temp tables, and an earlier
|
|
418
|
+
// partial recovery may already have dropped one. Its rows cannot and need not
|
|
419
|
+
// be restored into a table that no longer exists — that restore is a no-op —
|
|
420
|
+
// but its snapshot table and manifest row must still be cleaned up below.
|
|
421
|
+
// Without this guard the DELETE/INSERT throws "no such table" and wedges the
|
|
422
|
+
// durable object on every wake (prod token-usage rebuild, 2026-07-22).
|
|
423
|
+
const allSnapshotRows = rows.filter((row) => row.snapshot !== null && row.snapshot !== '');
|
|
424
|
+
const restorableRows = allSnapshotRows.filter((row) => tableExists(sql, row.original));
|
|
425
|
+
const restoredTables = restorableRows.map((row) => row.original);
|
|
426
|
+
const triggers = suspendTriggers(sql, restoredTables);
|
|
427
|
+
// Defer constraint checks across the whole atomic restore. Delete every
|
|
428
|
+
// snapshotted table before inserting any snapshot rows: interleaving those
|
|
429
|
+
// phases lets a later DELETE in a cyclic cascade erase an earlier restore.
|
|
430
|
+
// consumed on purpose: see the identical note in restoreSchemaSnapshot.
|
|
431
|
+
sql.exec('PRAGMA defer_foreign_keys = ON').toArray();
|
|
432
|
+
const snapshotRows = parentFirst(sql, restorableRows);
|
|
433
|
+
for (const row of snapshotRows) {
|
|
434
|
+
sql.exec(`DELETE FROM ${quoteIdent(row.original)}`);
|
|
435
|
+
}
|
|
436
|
+
for (const row of snapshotRows) {
|
|
437
|
+
const quotedTable = quoteIdent(row.original);
|
|
438
|
+
const quotedSnapshot = quoteIdent(row.snapshot);
|
|
439
|
+
// `SELECT *` also selects generated columns, which SQLite refuses to let
|
|
440
|
+
// any INSERT name, so restore the writable columns explicitly.
|
|
441
|
+
const columns = writableColumns(sql, row.original);
|
|
442
|
+
const columnList = columns.map(quoteIdent).join(', ');
|
|
443
|
+
sql.exec(`INSERT OR REPLACE INTO ${quotedTable} (${columnList}) SELECT ${columnList} FROM ${quotedSnapshot}`);
|
|
444
|
+
}
|
|
445
|
+
// Drop every snapshot table, including those whose original was gone: the
|
|
446
|
+
// snapshot is dead weight once the tx is being rolled back either way.
|
|
447
|
+
for (const row of allSnapshotRows) {
|
|
448
|
+
sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(row.snapshot)}`);
|
|
449
|
+
}
|
|
450
|
+
for (const row of rows) {
|
|
451
|
+
if (row.snapshot === null) {
|
|
452
|
+
sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(row.original)}`);
|
|
453
|
+
}
|
|
454
|
+
// Empty-string snapshots are row-journaled tables. Their before-images
|
|
455
|
+
// were restored by the owner's CDC undo callback before this function.
|
|
456
|
+
}
|
|
457
|
+
restoreTriggers(sql, triggers);
|
|
458
|
+
sql.exec(`DELETE FROM "${TX_MANIFEST_TABLE}" WHERE tx_id = ?`, txID);
|
|
459
|
+
if (schema.length > 0) {
|
|
460
|
+
sql.exec(`DELETE FROM "${TX_SCHEMA_TABLE}" WHERE tx_id = ?`, txID);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Point a transaction's journal entry for `table` at a real table snapshot.
|
|
465
|
+
*
|
|
466
|
+
* The transaction owner marks a parsed write row-journaled (an empty snapshot) before the
|
|
467
|
+
* DO has said whether it can capture that table. When CDC declines it, because
|
|
468
|
+
* the table has no stable row identity, that empty marker promises a row-level
|
|
469
|
+
* rollback nothing is able to perform. The owner therefore copies the table and
|
|
470
|
+
* rewrites the marker in the same storage transaction as the write, which puts
|
|
471
|
+
* the transaction back on the snapshot path recovery already knows how to
|
|
472
|
+
* replay. Safe to call repeatedly: only the first write to a table in a
|
|
473
|
+
* transaction takes the copy.
|
|
474
|
+
*/
|
|
475
|
+
export function upgradeToTableSnapshot(sql, txID, table, owner = 'default') {
|
|
476
|
+
sql.exec(TX_MANIFEST_DDL);
|
|
477
|
+
const existing = sql
|
|
478
|
+
.exec(`SELECT seq, snapshot FROM "${TX_MANIFEST_TABLE}" WHERE tx_id = ? AND original = ? ORDER BY seq`, txID, table)
|
|
479
|
+
.toArray();
|
|
480
|
+
// A null snapshot means the table did not exist at first write, so rollback
|
|
481
|
+
// drops it and no copy is needed. A non-empty one is already a real snapshot.
|
|
482
|
+
const snapshotted = existing.some((row) => row.snapshot === null || String(row.snapshot ?? '') !== '');
|
|
483
|
+
if (snapshotted)
|
|
484
|
+
return;
|
|
485
|
+
// Name the snapshot after the manifest row's seq, which is an AUTOINCREMENT
|
|
486
|
+
// primary key and therefore unique. Deriving the name from the table instead
|
|
487
|
+
// would have to sanitize it into an identifier, and that is not injective:
|
|
488
|
+
// `a-b` and `a_b` would collide on one snapshot table, so rolling the first
|
|
489
|
+
// one back would restore the other's rows and then drop the table the second
|
|
490
|
+
// still needs.
|
|
491
|
+
const seq = existing.length > 0
|
|
492
|
+
? Number(existing[0].seq)
|
|
493
|
+
: Number(sql
|
|
494
|
+
.exec(`INSERT INTO "${TX_MANIFEST_TABLE}" (tx_id, owner, original, snapshot) VALUES (?, ?, ?, '') RETURNING seq`, txID, owner, table)
|
|
495
|
+
.toArray()[0]?.seq);
|
|
496
|
+
const snapshot = `_orez_tx_undo_${seq}`;
|
|
497
|
+
sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(snapshot)}`);
|
|
498
|
+
sql.exec(`CREATE TABLE ${quoteIdent(snapshot)} AS SELECT * FROM ${quoteIdent(table)}`);
|
|
499
|
+
sql.exec(`UPDATE "${TX_MANIFEST_TABLE}" SET snapshot = ? WHERE seq = ?`, snapshot, seq);
|
|
500
|
+
}
|
|
501
|
+
const TRIGGER_WRITE = /\b(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO|UPDATE(?:\s+OR\s+\w+)?|DELETE\s+FROM)\s+(?:"((?:[^"]|"")+)"|`((?:[^`]|``)+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*))(?:\s*\.\s*(?:"((?:[^"]|"")+)"|`((?:[^`]|``)+)`|\[([^\]]+)\]|([A-Za-z_][\w$]*)))?/i;
|
|
502
|
+
function triggerWriteTargets(sql) {
|
|
503
|
+
if (!sql)
|
|
504
|
+
return null;
|
|
505
|
+
const begin = sql.search(/\bBEGIN\b/i);
|
|
506
|
+
const end = sql.search(/\bEND\s*$/i);
|
|
507
|
+
if (begin < 0 || end < begin)
|
|
508
|
+
return null;
|
|
509
|
+
const targets = [];
|
|
510
|
+
for (const statement of sql.slice(begin + 5, end).split(';')) {
|
|
511
|
+
if (!/\b(?:INSERT|REPLACE|UPDATE|DELETE)\b/i.test(statement))
|
|
512
|
+
continue;
|
|
513
|
+
const match = TRIGGER_WRITE.exec(statement);
|
|
514
|
+
if (!match)
|
|
515
|
+
return null;
|
|
516
|
+
const target = match[5] ??
|
|
517
|
+
match[6] ??
|
|
518
|
+
match[7] ??
|
|
519
|
+
match[8] ??
|
|
520
|
+
match[1] ??
|
|
521
|
+
match[2] ??
|
|
522
|
+
match[3] ??
|
|
523
|
+
match[4];
|
|
524
|
+
if (!target)
|
|
525
|
+
return null;
|
|
526
|
+
targets.push(target.replaceAll('""', '"').replaceAll('``', '`'));
|
|
527
|
+
}
|
|
528
|
+
return targets;
|
|
529
|
+
}
|
|
530
|
+
// sqlite's built-in identifier comparison folds ASCII case only.
|
|
531
|
+
function sqliteNoCase(identifier) {
|
|
532
|
+
return identifier.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
533
|
+
}
|
|
534
|
+
const ROW_OPERATIONS = ['INSERT', 'UPDATE', 'DELETE'];
|
|
535
|
+
// The event keyword sits immediately before `ON <table>`, or before `OF
|
|
536
|
+
// <columns>` for a column-scoped UPDATE trigger. Anchoring on that separator
|
|
537
|
+
// distinguishes the event from a trigger *named* insert/update/delete.
|
|
538
|
+
const TRIGGER_EVENT = /\b(DELETE|INSERT|UPDATE)\s+(?:OF\b|ON\b)/i;
|
|
539
|
+
function triggerEvent(sql) {
|
|
540
|
+
if (!sql)
|
|
541
|
+
return null;
|
|
542
|
+
const begin = sql.search(/\bBEGIN\b/i);
|
|
543
|
+
const match = TRIGGER_EVENT.exec(begin < 0 ? sql : sql.slice(0, begin));
|
|
544
|
+
return match ? match[1].toUpperCase() : null;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* snapshot the source table and the transitive targets of its business
|
|
548
|
+
* triggers and cascading/SET foreign keys. trigger SQL and FK metadata name
|
|
549
|
+
* every table SQLite can mutate implicitly, so copying unrelated published
|
|
550
|
+
* tables only multiplies billable writes during zero-cache startup. if a
|
|
551
|
+
* reachable trigger cannot be understood, retain the conservative all-table
|
|
552
|
+
* fallback.
|
|
553
|
+
*
|
|
554
|
+
* reachability is per (table, operation), because SQLite fires implicit writes
|
|
555
|
+
* per operation: `ON DELETE CASCADE` only fires when the parent row is deleted,
|
|
556
|
+
* and `AFTER DELETE` triggers only fire on a delete. discovering side effects
|
|
557
|
+
* from the table alone made every UPDATE pay for its children's delete
|
|
558
|
+
* cascades. soot's five-second `sootAgent.runtimeClaimExpiresAt` renewal copied
|
|
559
|
+
* the whole `agentEvent` table on each beat purely because `agentEvent`
|
|
560
|
+
* references `sootAgent` `ON DELETE CASCADE`: 35 billable rows with an empty
|
|
561
|
+
* event table, 1,035 with 1,000 retained events, and the rolling write budget
|
|
562
|
+
* tripped from heartbeats alone once the table grew.
|
|
563
|
+
*/
|
|
564
|
+
export function snapshotSideEffectWriteTables(sql, txID, sourceTable, operation) {
|
|
565
|
+
const relations = sql
|
|
566
|
+
.exec("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') " +
|
|
567
|
+
"AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' ORDER BY name")
|
|
568
|
+
.toArray()
|
|
569
|
+
.map((row) => ({ name: String(row.name ?? ''), type: String(row.type ?? '') }))
|
|
570
|
+
.filter((relation) => relation.name);
|
|
571
|
+
const knownRelations = new Set(relations.map((relation) => sqliteNoCase(relation.name)));
|
|
572
|
+
const snapshotTables = new Map(relations
|
|
573
|
+
.filter((relation) => relation.type === 'table' &&
|
|
574
|
+
!relation.name.startsWith('_orez_') &&
|
|
575
|
+
!relation.name.startsWith('_zero_'))
|
|
576
|
+
.map((relation) => [sqliteNoCase(relation.name), relation.name]));
|
|
577
|
+
const node = (table, op) => `${sqliteNoCase(table)}\0${op}`;
|
|
578
|
+
const edges = new Map();
|
|
579
|
+
const unsafeNodes = new Set();
|
|
580
|
+
const addEdge = (from, fromOp, to, toOps) => {
|
|
581
|
+
const fromKey = node(from, fromOp);
|
|
582
|
+
const targets = edges.get(fromKey) ?? new Set();
|
|
583
|
+
for (const toOp of toOps)
|
|
584
|
+
targets.add(node(to, toOp));
|
|
585
|
+
edges.set(fromKey, targets);
|
|
586
|
+
};
|
|
587
|
+
const triggers = sql
|
|
588
|
+
.exec("SELECT tbl_name, sql FROM sqlite_master WHERE type = 'trigger' " +
|
|
589
|
+
"AND name NOT GLOB '_orez_cdc_*'")
|
|
590
|
+
.toArray();
|
|
591
|
+
for (const row of triggers) {
|
|
592
|
+
const from = String(row.tbl_name ?? '');
|
|
593
|
+
const definition = row.sql === null || row.sql === undefined ? null : String(row.sql);
|
|
594
|
+
// an unreadable header hides which operation fires the trigger, so every
|
|
595
|
+
// operation on its table has to assume it does.
|
|
596
|
+
const event = triggerEvent(definition);
|
|
597
|
+
const firedBy = event ? [event] : ROW_OPERATIONS;
|
|
598
|
+
const targets = triggerWriteTargets(definition);
|
|
599
|
+
const unknownTarget = !targets || targets.some((target) => !knownRelations.has(sqliteNoCase(target)));
|
|
600
|
+
for (const firing of firedBy) {
|
|
601
|
+
if (unknownTarget) {
|
|
602
|
+
unsafeNodes.add(node(from, firing));
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// reading which operation a trigger body performs would have to model
|
|
606
|
+
// REPLACE conflict resolution deleting rows, so assume all three on the
|
|
607
|
+
// target. only whether the trigger fires at all is decided precisely.
|
|
608
|
+
for (const target of targets)
|
|
609
|
+
addEdge(from, firing, target, ROW_OPERATIONS);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const child of relations) {
|
|
613
|
+
if (child.type !== 'table')
|
|
614
|
+
continue;
|
|
615
|
+
for (const row of sql
|
|
616
|
+
.exec(`PRAGMA foreign_key_list(${quoteIdent(child.name)})`)
|
|
617
|
+
.toArray()) {
|
|
618
|
+
const parent = String(row.table ?? '');
|
|
619
|
+
if (!parent)
|
|
620
|
+
continue;
|
|
621
|
+
const onDelete = String(row.on_delete ?? 'NO ACTION').toUpperCase();
|
|
622
|
+
const onUpdate = String(row.on_update ?? 'NO ACTION').toUpperCase();
|
|
623
|
+
// deleting a parent row deletes cascaded children and rewrites SET ones.
|
|
624
|
+
if (onDelete === 'CASCADE')
|
|
625
|
+
addEdge(parent, 'DELETE', child.name, ['DELETE']);
|
|
626
|
+
else if (onDelete === 'SET NULL' || onDelete === 'SET DEFAULT')
|
|
627
|
+
addEdge(parent, 'DELETE', child.name, ['UPDATE']);
|
|
628
|
+
// updating a parent key rewrites children under every non-restricting
|
|
629
|
+
// action. which columns the statement touched is not known here, so any
|
|
630
|
+
// parent update follows the ON UPDATE edge.
|
|
631
|
+
if (onUpdate === 'CASCADE' || onUpdate === 'SET NULL' || onUpdate === 'SET DEFAULT')
|
|
632
|
+
addEdge(parent, 'UPDATE', child.name, ['UPDATE']);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// an upsert resolves to an insert or an update and only SQLite knows which,
|
|
636
|
+
// so it seeds both. an insert fires no referential action.
|
|
637
|
+
const seeded = operation === 'UPSERT' ? ['INSERT', 'UPDATE'] : [operation];
|
|
638
|
+
const reachable = new Set();
|
|
639
|
+
const reachableTables = new Set();
|
|
640
|
+
const pending = seeded.map((op) => node(sourceTable, op));
|
|
641
|
+
let hasSideEffect = false;
|
|
642
|
+
let mustSnapshotAll = false;
|
|
643
|
+
while (pending.length > 0) {
|
|
644
|
+
const current = pending.pop();
|
|
645
|
+
if (reachable.has(current))
|
|
646
|
+
continue;
|
|
647
|
+
reachable.add(current);
|
|
648
|
+
reachableTables.add(current.slice(0, current.indexOf('\0')));
|
|
649
|
+
if (unsafeNodes.has(current))
|
|
650
|
+
mustSnapshotAll = true;
|
|
651
|
+
for (const target of edges.get(current) ?? []) {
|
|
652
|
+
hasSideEffect = true;
|
|
653
|
+
pending.push(target);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (!hasSideEffect && !mustSnapshotAll)
|
|
657
|
+
return false;
|
|
658
|
+
const selected = mustSnapshotAll
|
|
659
|
+
? [...snapshotTables.values()]
|
|
660
|
+
: [...reachableTables]
|
|
661
|
+
.map((table) => snapshotTables.get(table))
|
|
662
|
+
.filter((table) => table !== undefined);
|
|
663
|
+
for (const table of selected.sort())
|
|
664
|
+
upgradeToTableSnapshot(sql, txID, table);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* roll back every journaled transaction belonging to `owner` (or every
|
|
669
|
+
* transaction when owner is undefined), and sweep `_orez_tx_*` snapshot
|
|
670
|
+
* tables no manifest row references (leftovers from pre-journal code).
|
|
671
|
+
*
|
|
672
|
+
* callers must guarantee none of the matched transactions can still be live
|
|
673
|
+
* — i.e. the owning process generation is dead (embed boot before opening pg
|
|
674
|
+
* sessions, or a store whose only client lives in the same isolate).
|
|
675
|
+
*
|
|
676
|
+
* returns the recovered transaction ids so callers can clean up associated
|
|
677
|
+
* state (e.g. pending tracked changes).
|
|
678
|
+
*/
|
|
679
|
+
export function recoverTxJournal(sql, owner, beforeRollback) {
|
|
680
|
+
const recovered = [];
|
|
681
|
+
const transactionIDs = new Set();
|
|
682
|
+
for (const table of [TX_MANIFEST_TABLE, TX_SCHEMA_TABLE]) {
|
|
683
|
+
const exists = table === TX_MANIFEST_TABLE ? manifestTableExists(sql) : schemaTableExists(sql);
|
|
684
|
+
if (!exists)
|
|
685
|
+
continue;
|
|
686
|
+
const txRows = owner === undefined
|
|
687
|
+
? sql.exec(`SELECT DISTINCT tx_id FROM ${quoteIdent(table)}`).toArray()
|
|
688
|
+
: sql
|
|
689
|
+
.exec(`SELECT DISTINCT tx_id FROM ${quoteIdent(table)} WHERE owner = ?`, owner)
|
|
690
|
+
.toArray();
|
|
691
|
+
for (const row of txRows)
|
|
692
|
+
transactionIDs.add(String(row.tx_id));
|
|
693
|
+
}
|
|
694
|
+
for (const txID of transactionIDs) {
|
|
695
|
+
beforeRollback?.(txID);
|
|
696
|
+
rollbackTxJournal(sql, txID);
|
|
697
|
+
recovered.push(txID);
|
|
698
|
+
}
|
|
699
|
+
// sweep snapshot tables nothing references: pre-journal leftovers from a
|
|
700
|
+
// killed tx. live transactions (any owner) are protected by their manifest
|
|
701
|
+
// rows, which are created atomically with the snapshot table.
|
|
702
|
+
const referenced = new Set(manifestTableExists(sql)
|
|
703
|
+
? sql
|
|
704
|
+
.exec(`SELECT snapshot FROM "${TX_MANIFEST_TABLE}" WHERE snapshot IS NOT NULL`)
|
|
705
|
+
.toArray()
|
|
706
|
+
.map((row) => String(row.snapshot))
|
|
707
|
+
: []);
|
|
708
|
+
const orphanTables = sql
|
|
709
|
+
.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '\\_orez\\_tx\\_%' ESCAPE '\\' AND name != ?", TX_MANIFEST_TABLE)
|
|
710
|
+
.toArray()
|
|
711
|
+
.map((row) => String(row.name))
|
|
712
|
+
.filter((name) => name !== TX_SCHEMA_TABLE && !referenced.has(name));
|
|
713
|
+
for (const name of orphanTables)
|
|
714
|
+
dropTable(sql, name);
|
|
715
|
+
return recovered;
|
|
716
|
+
}
|
|
717
|
+
//# sourceMappingURL=tx-journal.js.map
|