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.
Files changed (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -0
  3. package/dist/browser/generated/sqlite3-browser.wasm +0 -0
  4. package/dist/browser/generated/sync_wasm.js +922 -0
  5. package/dist/browser/generated/sync_wasm_bg.wasm +0 -0
  6. package/dist/browser/host.d.ts +10 -0
  7. package/dist/browser/host.d.ts.map +1 -0
  8. package/dist/browser/host.js +496 -0
  9. package/dist/browser/host.js.map +1 -0
  10. package/dist/browser/idb-snapshot.d.ts +11 -0
  11. package/dist/browser/idb-snapshot.d.ts.map +1 -0
  12. package/dist/browser/idb-snapshot.js +122 -0
  13. package/dist/browser/idb-snapshot.js.map +1 -0
  14. package/dist/browser/index.d.ts +5 -0
  15. package/dist/browser/index.d.ts.map +1 -0
  16. package/dist/browser/index.js +5 -0
  17. package/dist/browser/index.js.map +1 -0
  18. package/dist/browser/message-port.d.ts +7 -0
  19. package/dist/browser/message-port.d.ts.map +1 -0
  20. package/dist/browser/message-port.js +188 -0
  21. package/dist/browser/message-port.js.map +1 -0
  22. package/dist/browser/sqlite-adapter.d.ts +53 -0
  23. package/dist/browser/sqlite-adapter.d.ts.map +1 -0
  24. package/dist/browser/sqlite-adapter.js +126 -0
  25. package/dist/browser/sqlite-adapter.js.map +1 -0
  26. package/dist/browser/types.d.ts +52 -0
  27. package/dist/browser/types.d.ts.map +1 -0
  28. package/dist/browser/types.js +2 -0
  29. package/dist/browser/types.js.map +1 -0
  30. package/dist/cf-deploy/bundle.d.ts +18 -0
  31. package/dist/cf-deploy/bundle.d.ts.map +1 -0
  32. package/dist/cf-deploy/bundle.js +273 -0
  33. package/dist/cf-deploy/bundle.js.map +1 -0
  34. package/dist/cf-deploy/config.d.ts +12 -0
  35. package/dist/cf-deploy/config.d.ts.map +1 -0
  36. package/dist/cf-deploy/config.js +11 -0
  37. package/dist/cf-deploy/config.js.map +1 -0
  38. package/dist/cf-deploy/index.d.ts +10 -0
  39. package/dist/cf-deploy/index.d.ts.map +1 -0
  40. package/dist/cf-deploy/index.js +12 -0
  41. package/dist/cf-deploy/index.js.map +1 -0
  42. package/dist/cf-deploy/leaves.d.ts +8 -0
  43. package/dist/cf-deploy/leaves.d.ts.map +1 -0
  44. package/dist/cf-deploy/leaves.js +66 -0
  45. package/dist/cf-deploy/leaves.js.map +1 -0
  46. package/dist/cf-deploy/migration.d.ts +27 -0
  47. package/dist/cf-deploy/migration.d.ts.map +1 -0
  48. package/dist/cf-deploy/migration.js +734 -0
  49. package/dist/cf-deploy/migration.js.map +1 -0
  50. package/dist/cf-deploy/nativeMigrations.d.ts +24 -0
  51. package/dist/cf-deploy/nativeMigrations.d.ts.map +1 -0
  52. package/dist/cf-deploy/nativeMigrations.js +185 -0
  53. package/dist/cf-deploy/nativeMigrations.js.map +1 -0
  54. package/dist/cf-deploy/prune.d.ts +14 -0
  55. package/dist/cf-deploy/prune.d.ts.map +1 -0
  56. package/dist/cf-deploy/prune.js +235 -0
  57. package/dist/cf-deploy/prune.js.map +1 -0
  58. package/dist/cf-deploy/readiness.d.ts +11 -0
  59. package/dist/cf-deploy/readiness.d.ts.map +1 -0
  60. package/dist/cf-deploy/readiness.js +56 -0
  61. package/dist/cf-deploy/readiness.js.map +1 -0
  62. package/dist/cf-deploy/sources.d.ts +3 -0
  63. package/dist/cf-deploy/sources.d.ts.map +1 -0
  64. package/dist/cf-deploy/sources.js +24 -0
  65. package/dist/cf-deploy/sources.js.map +1 -0
  66. package/dist/cf-deploy/wrangler.d.ts +9 -0
  67. package/dist/cf-deploy/wrangler.d.ts.map +1 -0
  68. package/dist/cf-deploy/wrangler.js +48 -0
  69. package/dist/cf-deploy/wrangler.js.map +1 -0
  70. package/dist/cf-do/application-sql.d.ts +49 -0
  71. package/dist/cf-do/application-sql.d.ts.map +1 -0
  72. package/dist/cf-do/application-sql.js +124 -0
  73. package/dist/cf-do/application-sql.js.map +1 -0
  74. package/dist/cf-do/cdc.d.ts +155 -0
  75. package/dist/cf-do/cdc.d.ts.map +1 -0
  76. package/dist/cf-do/cdc.js +723 -0
  77. package/dist/cf-do/cdc.js.map +1 -0
  78. package/dist/cf-do/cursor-pull.d.ts +27 -0
  79. package/dist/cf-do/cursor-pull.d.ts.map +1 -0
  80. package/dist/cf-do/cursor-pull.js +78 -0
  81. package/dist/cf-do/cursor-pull.js.map +1 -0
  82. package/dist/cf-do/lite-data-worker.d.ts +171 -0
  83. package/dist/cf-do/lite-data-worker.d.ts.map +1 -0
  84. package/dist/cf-do/lite-data-worker.js +685 -0
  85. package/dist/cf-do/lite-data-worker.js.map +1 -0
  86. package/dist/cf-do/namespace-backup.d.ts +85 -0
  87. package/dist/cf-do/namespace-backup.d.ts.map +1 -0
  88. package/dist/cf-do/namespace-backup.js +470 -0
  89. package/dist/cf-do/namespace-backup.js.map +1 -0
  90. package/dist/cf-do/row-undo.d.ts +36 -0
  91. package/dist/cf-do/row-undo.d.ts.map +1 -0
  92. package/dist/cf-do/row-undo.js +221 -0
  93. package/dist/cf-do/row-undo.js.map +1 -0
  94. package/dist/cf-do/tx-journal.d.ts +118 -0
  95. package/dist/cf-do/tx-journal.d.ts.map +1 -0
  96. package/dist/cf-do/tx-journal.js +717 -0
  97. package/dist/cf-do/tx-journal.js.map +1 -0
  98. package/dist/cf-do/watermark.d.ts +29 -0
  99. package/dist/cf-do/watermark.d.ts.map +1 -0
  100. package/dist/cf-do/watermark.js +105 -0
  101. package/dist/cf-do/watermark.js.map +1 -0
  102. package/dist/cf-do/worker.d.ts +282 -0
  103. package/dist/cf-do/worker.d.ts.map +1 -0
  104. package/dist/cf-do/worker.js +1999 -0
  105. package/dist/cf-do/worker.js.map +1 -0
  106. package/dist/client.d.ts +2 -0
  107. package/dist/client.d.ts.map +1 -0
  108. package/dist/client.js +2 -0
  109. package/dist/client.js.map +1 -0
  110. package/dist/cloudflare-build.d.ts +9 -0
  111. package/dist/cloudflare-build.d.ts.map +1 -0
  112. package/dist/cloudflare-build.js +9 -0
  113. package/dist/cloudflare-build.js.map +1 -0
  114. package/dist/cloudflare-bun-wasm-loader.d.ts +2 -0
  115. package/dist/cloudflare-bun-wasm-loader.d.ts.map +1 -0
  116. package/dist/cloudflare-bun-wasm-loader.js +2 -0
  117. package/dist/cloudflare-bun-wasm-loader.js.map +1 -0
  118. package/dist/cloudflare-query-compiler.d.ts +2 -0
  119. package/dist/cloudflare-query-compiler.d.ts.map +1 -0
  120. package/dist/cloudflare-query-compiler.js +2 -0
  121. package/dist/cloudflare-query-compiler.js.map +1 -0
  122. package/dist/cloudflare-runtime.d.ts +15 -0
  123. package/dist/cloudflare-runtime.d.ts.map +1 -0
  124. package/dist/cloudflare-runtime.js +11 -0
  125. package/dist/cloudflare-runtime.js.map +1 -0
  126. package/dist/cloudflare-sync.d.ts +3 -0
  127. package/dist/cloudflare-sync.d.ts.map +1 -0
  128. package/dist/cloudflare-sync.js +3 -0
  129. package/dist/cloudflare-sync.js.map +1 -0
  130. package/dist/cloudflare-transaction-query.d.ts +2 -0
  131. package/dist/cloudflare-transaction-query.d.ts.map +1 -0
  132. package/dist/cloudflare-transaction-query.js +2 -0
  133. package/dist/cloudflare-transaction-query.js.map +1 -0
  134. package/dist/cloudflare-vite-wasm-loader.d.ts +2 -0
  135. package/dist/cloudflare-vite-wasm-loader.d.ts.map +1 -0
  136. package/dist/cloudflare-vite-wasm-loader.js +2 -0
  137. package/dist/cloudflare-vite-wasm-loader.js.map +1 -0
  138. package/dist/cloudflare.d.ts +3 -0
  139. package/dist/cloudflare.d.ts.map +1 -0
  140. package/dist/cloudflare.js +3 -0
  141. package/dist/cloudflare.js.map +1 -0
  142. package/dist/do-sql-tracking.d.ts +114 -0
  143. package/dist/do-sql-tracking.d.ts.map +1 -0
  144. package/dist/do-sql-tracking.js +280 -0
  145. package/dist/do-sql-tracking.js.map +1 -0
  146. package/dist/index.d.ts +4 -0
  147. package/dist/index.d.ts.map +1 -0
  148. package/dist/index.js +3 -0
  149. package/dist/index.js.map +1 -0
  150. package/dist/wake.d.ts +22 -0
  151. package/dist/wake.d.ts.map +1 -0
  152. package/dist/wake.js +114 -0
  153. package/dist/wake.js.map +1 -0
  154. package/dist/worker/cf-do-shim.d.ts +53 -0
  155. package/dist/worker/cf-do-shim.d.ts.map +1 -0
  156. package/dist/worker/cf-do-shim.js +66 -0
  157. package/dist/worker/cf-do-shim.js.map +1 -0
  158. package/dist/zero-http/encrypted-column-codec.d.ts +46 -0
  159. package/dist/zero-http/encrypted-column-codec.d.ts.map +1 -0
  160. package/dist/zero-http/encrypted-column-codec.js +676 -0
  161. package/dist/zero-http/encrypted-column-codec.js.map +1 -0
  162. package/dist/zero-http/encrypted-column-conformance.d.ts +7 -0
  163. package/dist/zero-http/encrypted-column-conformance.d.ts.map +1 -0
  164. package/dist/zero-http/encrypted-column-conformance.js +161 -0
  165. package/dist/zero-http/encrypted-column-conformance.js.map +1 -0
  166. package/dist/zero-http/fixture-schema.d.ts +127 -0
  167. package/dist/zero-http/fixture-schema.d.ts.map +1 -0
  168. package/dist/zero-http/fixture-schema.js +56 -0
  169. package/dist/zero-http/fixture-schema.js.map +1 -0
  170. package/dist/zero-http/mount.d.ts +71 -0
  171. package/dist/zero-http/mount.d.ts.map +1 -0
  172. package/dist/zero-http/mount.js +594 -0
  173. package/dist/zero-http/mount.js.map +1 -0
  174. package/dist/zero-http/payload-codec.d.ts +91 -0
  175. package/dist/zero-http/payload-codec.d.ts.map +1 -0
  176. package/dist/zero-http/payload-codec.js +10 -0
  177. package/dist/zero-http/payload-codec.js.map +1 -0
  178. package/dist/zero-http/server.d.ts +16 -0
  179. package/dist/zero-http/server.d.ts.map +1 -0
  180. package/dist/zero-http/server.js +270 -0
  181. package/dist/zero-http/server.js.map +1 -0
  182. package/dist/zero-http/test-harness.d.ts +32 -0
  183. package/dist/zero-http/test-harness.d.ts.map +1 -0
  184. package/dist/zero-http/test-harness.js +77 -0
  185. package/dist/zero-http/test-harness.js.map +1 -0
  186. package/dist/zero-http/transport.d.ts +71 -0
  187. package/dist/zero-http/transport.d.ts.map +1 -0
  188. package/dist/zero-http/transport.js +1106 -0
  189. package/dist/zero-http/transport.js.map +1 -0
  190. 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