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,1999 @@
1
+ // @ts-nocheck — cloudflare:workers types not available in orez
2
+ import { DurableObject, RpcTarget } from 'cloudflare:workers';
3
+ import { executeTransactionQueryPlan, } from 'orez-sync-cf-host/transaction-query';
4
+ import { classifySql, isSqlMutation, isSqlRowMutation, RollingRowWriteBudget, stripPublicPrefix, trackSqlCursorRowsWritten, trackedChangeRow, WriteBudgetExceededError, } from '../do-sql-tracking.js';
5
+ import { TransactionalCdc, schemaChangeTargets, } from './cdc.js';
6
+ import { appendPendingChange, deletePendingChanges, ensurePendingChangesTable, rollbackPendingChanges, } from './row-undo.js';
7
+ import { beginTxJournal, commitTxJournal, recoverTxJournal, rollbackTxJournal, snapshotSideEffectWriteTables, snapshotTxSchema, upgradeToTableSnapshot, } from './tx-journal.js';
8
+ import { DurableWatermarkState } from './watermark.js';
9
+ export { createApplicationSqlClient } from './application-sql.js';
10
+ const SCHEMA_VERSION = 1;
11
+ const SQL_ERROR_SNIPPET_RADIUS = 1600;
12
+ const SQL_ERROR_FALLBACK_LIMIT = 4000;
13
+ const DEFAULT_WRITE_BUDGET_ROWS = 150_000;
14
+ const DEFAULT_WRITE_BUDGET_WINDOW_MS = 5 * 60 * 1000;
15
+ const WRITE_BUDGET_TRIPPED_KEY = '_orez_write_budget_tripped_at';
16
+ const SCHEMA_PROVISIONING_WAIT_MS = 20_000;
17
+ const SCHEMA_PROVISIONING_MAX_DELAY_MS = 500;
18
+ const APPLICATION_SQL_TURN_WAIT_MS = 30_000;
19
+ const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
20
+ const MAX_SNAPSHOT_PAGE_ROWS = 10_000;
21
+ const TRANSACTION_CONTROL_SQL = /^\s*(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?=\s|;|$)/i;
22
+ function positiveEnvInteger(value, fallback) {
23
+ const parsed = Number(value);
24
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
25
+ }
26
+ function quoteIdent(name) {
27
+ return `"${name.replace(/"/g, '""')}"`;
28
+ }
29
+ function sqliteTypeForSchemaColumn(type) {
30
+ const types = {
31
+ string: 'TEXT',
32
+ number: 'REAL',
33
+ boolean: 'INTEGER',
34
+ json: 'TEXT',
35
+ bigint: 'TEXT',
36
+ };
37
+ return types[type] || 'TEXT';
38
+ }
39
+ function sqliteErrorOffset(message) {
40
+ const marker = 'offset ';
41
+ const start = message.indexOf(marker);
42
+ if (start < 0)
43
+ return null;
44
+ let index = start + marker.length;
45
+ let digits = '';
46
+ while (index < message.length) {
47
+ const code = message.charCodeAt(index);
48
+ if (code < 48 || code > 57)
49
+ break;
50
+ digits += message[index];
51
+ index++;
52
+ }
53
+ if (!digits)
54
+ return null;
55
+ const offset = Number(digits);
56
+ return Number.isFinite(offset) ? offset : null;
57
+ }
58
+ function sqlErrorSnippet(sql, message) {
59
+ const offset = sqliteErrorOffset(message);
60
+ if (offset !== null) {
61
+ const start = Math.max(0, offset - SQL_ERROR_SNIPPET_RADIUS);
62
+ const end = Math.min(sql.length, offset + SQL_ERROR_SNIPPET_RADIUS);
63
+ return `${start > 0 ? '...' : ''}${sql.slice(start, end)}${end < sql.length ? '...' : ''}`;
64
+ }
65
+ if (sql.length <= SQL_ERROR_FALLBACK_LIMIT)
66
+ return sql;
67
+ return `${sql.slice(0, SQL_ERROR_FALLBACK_LIMIT)}...`;
68
+ }
69
+ function assertApplicationTransactionSQL(sql) {
70
+ if (TRANSACTION_CONTROL_SQL.test(sql)) {
71
+ throw new TypeError('transaction SQL is owned by ZeroDO');
72
+ }
73
+ }
74
+ function applicationSqlTrack(metadata) {
75
+ if (!metadata)
76
+ return undefined;
77
+ const operations = {
78
+ insert: 'INSERT',
79
+ update: 'UPDATE',
80
+ delete: 'DELETE',
81
+ upsert: 'UPSERT',
82
+ };
83
+ return {
84
+ tableName: metadata.publicTable,
85
+ physicalTableName: metadata.table,
86
+ operation: operations[metadata.kind],
87
+ };
88
+ }
89
+ const APPLICATION_SQL_ACQUIRE = Symbol('applicationSqlAcquire');
90
+ const APPLICATION_SQL_QUERY = Symbol('applicationSqlQuery');
91
+ const APPLICATION_SQL_EXEC = Symbol('applicationSqlExec');
92
+ const APPLICATION_SQL_QUERY_PLAN = Symbol('applicationSqlQueryPlan');
93
+ const APPLICATION_SQL_REGISTER_TABLES = Symbol('applicationSqlRegisterTables');
94
+ const APPLICATION_SQL_COMMIT = Symbol('applicationSqlCommit');
95
+ const APPLICATION_SQL_ROLLBACK = Symbol('applicationSqlRollback');
96
+ const APPLICATION_SQL_DISPOSE = Symbol('applicationSqlDispose');
97
+ class ApplicationSqlSessionTarget extends RpcTarget {
98
+ owner;
99
+ sessionID;
100
+ state = 'created';
101
+ changed = false;
102
+ mutated = false;
103
+ constructor(owner, sessionID) {
104
+ super();
105
+ this.owner = owner;
106
+ this.sessionID = sessionID;
107
+ }
108
+ async begin() {
109
+ return this.owner[APPLICATION_SQL_ACQUIRE](this);
110
+ }
111
+ query(sql, params = []) {
112
+ return this.owner[APPLICATION_SQL_QUERY](this, sql, params);
113
+ }
114
+ exec(sql, params = [], metadata) {
115
+ return this.owner[APPLICATION_SQL_EXEC](this, sql, params, metadata);
116
+ }
117
+ queryPlan(plan, queryName, queryBudget) {
118
+ return this.owner[APPLICATION_SQL_QUERY_PLAN](this, plan, queryName, queryBudget);
119
+ }
120
+ registerTables(tables) {
121
+ return this.owner[APPLICATION_SQL_REGISTER_TABLES](this, tables);
122
+ }
123
+ commit() {
124
+ return this.owner[APPLICATION_SQL_COMMIT](this);
125
+ }
126
+ rollback() {
127
+ return this.owner[APPLICATION_SQL_ROLLBACK](this);
128
+ }
129
+ [Symbol.dispose]() {
130
+ this.owner[APPLICATION_SQL_DISPOSE](this);
131
+ }
132
+ }
133
+ export class ZeroDO extends DurableObject {
134
+ sql;
135
+ watermarks;
136
+ cdc;
137
+ schemaTables = new Set();
138
+ tableSchemas = new Map();
139
+ writeBudget;
140
+ writeBudgetDisabled;
141
+ writeBudgetAdminToken;
142
+ activeWriteMeasurements = null;
143
+ writeBudgetTripStatement;
144
+ pendingChangesSchemaReady = false;
145
+ activeApplicationSqlSession = null;
146
+ applicationSqlDidCommit(_changed, _mutated) { }
147
+ recordWriteBudgetRows(rows, statement) {
148
+ const wasTripped = this.writeBudget.status().tripped;
149
+ try {
150
+ this.writeBudget.recordBillable(rows);
151
+ }
152
+ catch (error) {
153
+ if (error instanceof WriteBudgetExceededError && !wasTripped) {
154
+ this.writeBudgetTripStatement = statement
155
+ ? {
156
+ sql: statement.sql
157
+ .replace(/'(?:''|[^'])*'/g, '?')
158
+ .replace(/\b\d+(?:\.\d+)?\b/g, '?')
159
+ .replace(/\s+/g, ' ')
160
+ .trim()
161
+ .slice(0, 1_000),
162
+ rowsWritten: statement.rowsWritten,
163
+ }
164
+ : undefined;
165
+ const status = this.writeBudget.status();
166
+ console.error(JSON.stringify({
167
+ event: 'orez_do_write_budget_tripped',
168
+ windowRows: status.windowRows,
169
+ billableRows: status.billableRows,
170
+ logicalRows: status.logicalRows,
171
+ budget: status.budget,
172
+ windowMs: status.windowMs,
173
+ trippedAt: status.trippedAt,
174
+ statement: this.writeBudgetTripStatement,
175
+ }));
176
+ this.persistWriteBudgetTrip();
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+ /**
182
+ * Make the trip sticky, from outside the transaction that is aborting.
183
+ *
184
+ * The trip fires during cursor consumption, which is almost always inside
185
+ * ctx.storage.transaction(). A put in that scope is rolled back with the
186
+ * write (prod booted un-tripped this way on 2026-07-11), and code after the
187
+ * transaction does not reliably get to run either: measured under workerd,
188
+ * a write budget tripped on the application SQL path resolved neither the
189
+ * transaction's success nor its failure path, so a namespace tripped by
190
+ * soot's real write path persisted nothing and came back OPEN on the next
191
+ * eviction. blockConcurrencyWhile is the seam that survives both: the
192
+ * runtime defers this until the in-flight storage work is done, so it lands
193
+ * after the rollback rather than inside it.
194
+ *
195
+ * The count rides along with the timestamp. The rolling window keeps decaying
196
+ * while the circuit stays sticky and a restored object starts with none of
197
+ * it, so a status read that consults the live meter reports 0/budget and
198
+ * erases how far over the namespace actually went.
199
+ */
200
+ persistWriteBudgetTrip() {
201
+ const trip = this.writeBudget.trip();
202
+ if (!trip)
203
+ return;
204
+ void this.ctx
205
+ .blockConcurrencyWhile(async () => {
206
+ await this.ctx.storage.put(WRITE_BUDGET_TRIPPED_KEY, {
207
+ ...trip,
208
+ statement: this.writeBudgetTripStatement,
209
+ });
210
+ })
211
+ .catch((error) => {
212
+ console.error(JSON.stringify({
213
+ event: 'orez_do_write_budget_persist_failed',
214
+ message: error instanceof Error ? error.message : String(error),
215
+ }));
216
+ });
217
+ }
218
+ // The trip itself is persisted by persistWriteBudgetTrip at the moment it
219
+ // fires, which is the one place every path passes through, so this only has
220
+ // to shape the response.
221
+ writeBudgetErrorResponse(error) {
222
+ if (!(error instanceof WriteBudgetExceededError))
223
+ return null;
224
+ return Response.json(error.toJSON(), { status: 429 });
225
+ }
226
+ constructor(ctx, env) {
227
+ super(ctx, env);
228
+ this.sql = ctx.storage.sql;
229
+ this.writeBudgetDisabled = /^(?:1|true)$/i.test(env.OREZ_DO_WRITE_BUDGET_DISABLED ?? '');
230
+ this.writeBudgetAdminToken = env.OREZ_DO_WRITE_BUDGET_ADMIN_TOKEN;
231
+ this.writeBudget = new RollingRowWriteBudget({
232
+ budgetRows: positiveEnvInteger(env.OREZ_DO_WRITE_BUDGET_ROWS, DEFAULT_WRITE_BUDGET_ROWS),
233
+ windowMs: positiveEnvInteger(env.OREZ_DO_WRITE_BUDGET_WINDOW_MS, DEFAULT_WRITE_BUDGET_WINDOW_MS),
234
+ now: () => Date.now(),
235
+ });
236
+ if (this.writeBudgetDisabled) {
237
+ console.error(JSON.stringify({
238
+ event: 'orez_do_write_budget_disabled',
239
+ warning: 'row write circuit breaker explicitly disabled',
240
+ }));
241
+ }
242
+ const rawExec = this.sql.exec.bind(this.sql);
243
+ this.sql.exec = (statement, ...params) => {
244
+ const mutation = isSqlMutation(statement);
245
+ if (mutation && !this.writeBudgetDisabled)
246
+ this.writeBudget.assertOpen();
247
+ const cursor = rawExec(statement, ...params);
248
+ if (!mutation)
249
+ return cursor;
250
+ const measurement = {
251
+ sql: statement,
252
+ rowsWritten: 0,
253
+ };
254
+ if (this.activeWriteMeasurements)
255
+ this.activeWriteMeasurements.push(measurement);
256
+ return trackSqlCursorRowsWritten(cursor, (rows) => {
257
+ measurement.rowsWritten += rows;
258
+ if (!this.writeBudgetDisabled)
259
+ this.recordWriteBudgetRows(rows, measurement);
260
+ });
261
+ };
262
+ this.cdc = new TransactionalCdc(this.sql);
263
+ this.watermarks = new DurableWatermarkState(this.sql);
264
+ ctx.blockConcurrencyWhile(async () => {
265
+ const recovered = this.rollbackAtomicallyWithoutForeignKeys(() => {
266
+ const transactionIDs = recoverTxJournal(this.sql, 'application', (transactionID) => {
267
+ this.rollbackPendingTrackedChanges(transactionID);
268
+ });
269
+ for (const transactionID of transactionIDs)
270
+ this.deletePendingTrackedChanges(transactionID);
271
+ return transactionIDs;
272
+ });
273
+ if (recovered.length)
274
+ this.invalidateSchemaCaches();
275
+ if (!this.writeBudgetDisabled) {
276
+ const persisted = await ctx.storage.get(WRITE_BUDGET_TRIPPED_KEY);
277
+ if (persisted) {
278
+ this.writeBudget.restoreTrip(persisted);
279
+ if (typeof persisted !== 'number')
280
+ this.writeBudgetTripStatement = persisted.statement;
281
+ }
282
+ }
283
+ });
284
+ }
285
+ async fetch(request) {
286
+ const url = new URL(request.url);
287
+ if (request.method === 'OPTIONS') {
288
+ return new Response(null, {
289
+ headers: {
290
+ 'Access-Control-Allow-Origin': '*',
291
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
292
+ 'Access-Control-Allow-Headers': '*',
293
+ },
294
+ });
295
+ }
296
+ if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect'))
297
+ return this.handleSyncConnect(request, url);
298
+ if (url.pathname === '/_orez/write-budget' && request.method === 'GET')
299
+ return Response.json({
300
+ enabled: !this.writeBudgetDisabled,
301
+ ...this.writeBudget.status(),
302
+ trippedStatement: this.writeBudgetTripStatement,
303
+ });
304
+ if (url.pathname === '/_orez/write-budget/trip' && request.method === 'POST')
305
+ return this.handleWriteBudgetTrip(request);
306
+ if (url.pathname === '/_orez/write-budget/reopen' && request.method === 'POST')
307
+ return this.handleWriteBudgetReopen(request);
308
+ if ((url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
309
+ request.method === 'POST')
310
+ return this.handleHttpPush(request);
311
+ if (url.pathname === '/exec' && request.method === 'POST')
312
+ return this.handleExec(request);
313
+ if (url.pathname === '/batch' && request.method === 'POST')
314
+ return this.handleBatch(request);
315
+ if (url.pathname === '/snapshot-tx-schema' && request.method === 'POST')
316
+ return this.handleSnapshotTransactionSchema(request);
317
+ if (url.pathname === '/commit-tx' && request.method === 'POST')
318
+ return this.handleCommitTransaction(request);
319
+ if (url.pathname === '/rollback-tx' && request.method === 'POST')
320
+ return this.handleRollbackTransaction(request);
321
+ if (url.pathname === '/recover-txs' && request.method === 'POST')
322
+ return this.handleRecoverTransactions(request);
323
+ if (url.pathname === '/changes' &&
324
+ (request.method === 'GET' || request.method === 'POST'))
325
+ return this.handleChanges(request, url);
326
+ if (url.pathname === '/snapshot' && request.method === 'GET')
327
+ return this.withApplicationSqlTurn(() => this.handleSnapshot(url), request.signal);
328
+ if (url.pathname === '/notify' && request.method === 'POST')
329
+ return Response.json({ ok: true, cookie: this.cookie() });
330
+ return new Response('not found', { status: 404 });
331
+ }
332
+ async handleWriteBudgetReopen(request) {
333
+ if (!this.hasWriteBudgetAdminToken(request))
334
+ return Response.json({ error: 'forbidden' }, { status: 403 });
335
+ await this.ctx.storage.delete(WRITE_BUDGET_TRIPPED_KEY);
336
+ this.writeBudgetTripStatement = undefined;
337
+ const status = this.writeBudget.reopen();
338
+ console.log(JSON.stringify({ event: 'orez_do_write_budget_reopened', reopenedAt: Date.now() }));
339
+ return Response.json({ ok: true, enabled: !this.writeBudgetDisabled, ...status });
340
+ }
341
+ async handleWriteBudgetTrip(request) {
342
+ if (!this.hasWriteBudgetAdminToken(request))
343
+ return Response.json({ error: 'forbidden' }, { status: 403 });
344
+ const status = this.writeBudget.forceTrip();
345
+ this.writeBudgetTripStatement = {
346
+ sql: 'operator force-trip',
347
+ rowsWritten: 0,
348
+ };
349
+ await this.ctx.storage.put(WRITE_BUDGET_TRIPPED_KEY, {
350
+ ...this.writeBudget.trip(),
351
+ statement: this.writeBudgetTripStatement,
352
+ });
353
+ console.error(JSON.stringify({
354
+ event: 'orez_do_write_budget_force_tripped',
355
+ trippedAt: Date.now(),
356
+ }));
357
+ return Response.json({
358
+ ok: true,
359
+ enabled: !this.writeBudgetDisabled,
360
+ ...status,
361
+ trippedStatement: this.writeBudgetTripStatement,
362
+ });
363
+ }
364
+ hasWriteBudgetAdminToken(request) {
365
+ const supplied = request.headers.get('x-orez-admin-token') ??
366
+ request.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
367
+ return Boolean(this.writeBudgetAdminToken && supplied === this.writeBudgetAdminToken);
368
+ }
369
+ // ── Zero sync protocol ──────────────────────────────────────────────────
370
+ handleSyncConnect(request, url) {
371
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
372
+ return new Response('expected websocket upgrade', { status: 426 });
373
+ }
374
+ const pair = new WebSocketPair();
375
+ const client = pair[0];
376
+ const server = pair[1];
377
+ const clientID = url.searchParams.get('clientID') || 'anon';
378
+ const clientGroupID = url.searchParams.get('clientGroupID') || 'default';
379
+ const userID = url.searchParams.get('userID') || 'anon';
380
+ const wsid = url.searchParams.get('wsid') || crypto.randomUUID();
381
+ const baseCookie = url.searchParams.get('baseCookie');
382
+ this.ctx.acceptWebSocket(server);
383
+ server.serializeAttachment({
384
+ clientID,
385
+ clientGroupID,
386
+ userID,
387
+ cookie: baseCookie ? baseCookie : null,
388
+ initialized: false,
389
+ desiredTableNames: [],
390
+ desiredQueries: [],
391
+ });
392
+ this.sendJSON(server, ['connected', { wsid, timestamp: Date.now() }]);
393
+ const secProtocol = request.headers.get('sec-websocket-protocol');
394
+ if (secProtocol) {
395
+ const initData = decodeInitConnection(secProtocol);
396
+ if (initData) {
397
+ const clientSchema = initData[1]?.clientSchema;
398
+ const patch = (initData[1]?.desiredQueriesPatch || []);
399
+ this.applyDesiredQueries(server, patch, clientSchema);
400
+ }
401
+ }
402
+ return new Response(null, {
403
+ status: 101,
404
+ headers: secProtocol ? { 'Sec-WebSocket-Protocol': secProtocol } : undefined,
405
+ webSocket: client,
406
+ });
407
+ }
408
+ async webSocketMessage(socket, messageData) {
409
+ this.watermarks.ensureTables();
410
+ const ws = socket;
411
+ const attachment = this.readSocketAttachment(ws);
412
+ if (!attachment)
413
+ return;
414
+ const message = this.parseMessage(messageData);
415
+ if (!message)
416
+ return;
417
+ const body = message[1] || {};
418
+ switch (message[0]) {
419
+ case 'initConnection':
420
+ case 'changeDesiredQueries':
421
+ this.applyDesiredQueries(ws, (body.desiredQueriesPatch || []), body.clientSchema);
422
+ break;
423
+ case 'push':
424
+ this.handlePush(ws, attachment, message[1]);
425
+ break;
426
+ case 'pull':
427
+ this.handlePull(ws, message[1]);
428
+ break;
429
+ case 'ping':
430
+ this.sendJSON(ws, ['pong', {}]);
431
+ break;
432
+ }
433
+ }
434
+ webSocketClose(_socket, _code, _reason, _wasClean) { }
435
+ applyDesiredQueries(socket, patch, clientSchema) {
436
+ const attachment = this.readSocketAttachment(socket);
437
+ if (!attachment)
438
+ return;
439
+ if (clientSchema)
440
+ this.ensureSchemaTables(clientSchema);
441
+ let nextAttachment = this.applyDesiredQueryPatch(attachment, patch);
442
+ socket.serializeAttachment(nextAttachment);
443
+ if (!nextAttachment.initialized) {
444
+ nextAttachment = this.sendSyncPoke(socket, { ...nextAttachment, initialized: true }, { lastMutationIDChanges: {}, rowsPatch: [] });
445
+ }
446
+ if (patch.length === 0)
447
+ return;
448
+ const rowsPatch = [
449
+ { op: 'clear' },
450
+ ...this.rowsPatchForTables(nextAttachment.desiredTableNames),
451
+ ];
452
+ this.sendSyncPoke(socket, nextAttachment, {
453
+ gotQueriesPatch: this.gotQueriesPatch(patch),
454
+ rowsPatch,
455
+ });
456
+ }
457
+ applyDesiredQueryPatch(attachment, patch) {
458
+ const desiredQueries = new Map();
459
+ for (const query of attachment.desiredQueries || [])
460
+ desiredQueries.set(query.hash, query.tableNames);
461
+ for (const op of patch) {
462
+ if (op.op === 'clear') {
463
+ desiredQueries.clear();
464
+ }
465
+ else if (op.op === 'put' && op.hash) {
466
+ desiredQueries.set(op.hash, this.resolveTablesFromPatch([op]));
467
+ }
468
+ else if (op.op === 'del' && op.hash) {
469
+ desiredQueries.delete(op.hash);
470
+ }
471
+ }
472
+ const queries = [...desiredQueries.entries()].map(([hash, tableNames]) => ({
473
+ hash,
474
+ tableNames,
475
+ }));
476
+ return {
477
+ ...attachment,
478
+ desiredQueries: queries,
479
+ desiredTableNames: [...new Set(queries.flatMap((query) => query.tableNames))],
480
+ };
481
+ }
482
+ gotQueriesPatch(patch) {
483
+ const got = [];
484
+ for (const op of patch) {
485
+ if (op.op === 'clear')
486
+ got.push({ op: 'clear' });
487
+ else if (op.hash)
488
+ got.push({ op: op.op, hash: op.hash });
489
+ }
490
+ return got;
491
+ }
492
+ rowsPatchForTables(tableNames) {
493
+ const rowsPatch = [];
494
+ for (const tn of tableNames) {
495
+ if (!this.tableExists(tn))
496
+ continue;
497
+ for (const row of this.readAllRows(tn))
498
+ rowsPatch.push({ op: 'put', tableName: tn, value: row });
499
+ }
500
+ return rowsPatch;
501
+ }
502
+ resolveTablesFromPatch(patch) {
503
+ const tables = [];
504
+ for (const op of patch) {
505
+ const tableFromName = this.tableNameFromOperationName(op.name);
506
+ if (tableFromName)
507
+ tables.push(tableFromName);
508
+ if (op.ast)
509
+ this.extractTableFromAST(op.ast, tables);
510
+ }
511
+ return tables;
512
+ }
513
+ extractTableFromAST(ast, tables) {
514
+ if (ast?.table)
515
+ tables.push(ast.table);
516
+ if (ast?.related)
517
+ for (const rel of ast.related) {
518
+ if (rel?.subquery?.table)
519
+ tables.push(rel.subquery.table);
520
+ if (rel?.subquery?.related)
521
+ this.extractTableFromAST(rel.subquery, tables);
522
+ }
523
+ }
524
+ handlePush(socket, attachment, body) {
525
+ const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
526
+ const before = this.watermark();
527
+ const mutationResults = [];
528
+ const lastMutationIDChanges = {};
529
+ for (const m of mutations) {
530
+ const result = this.applyMutation(m);
531
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
532
+ lastMutationIDChanges[m.clientID] = m.id;
533
+ }
534
+ this.sendJSON(socket, ['pushResponse', { mutations: mutationResults }]);
535
+ const after = this.watermark();
536
+ const changes = after > before ? this.readChangesSince(before) : [];
537
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
538
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
539
+ this.broadcastMutationPoke(attachment, {
540
+ lastMutationIDChanges,
541
+ rowsPatch,
542
+ });
543
+ }
544
+ async handleHttpPush(request) {
545
+ try {
546
+ const body = (await request.json());
547
+ const before = this.watermark();
548
+ const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
549
+ const mutationResults = [];
550
+ const lastMutationIDChanges = {};
551
+ for (const m of mutations) {
552
+ const result = this.applyMutation(m);
553
+ mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
554
+ lastMutationIDChanges[m.clientID] = m.id;
555
+ }
556
+ const after = this.watermark();
557
+ const changes = after > before ? this.readChangesSince(before) : [];
558
+ const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
559
+ if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
560
+ this.broadcastPoke(body?.clientGroupID || 'default', {
561
+ lastMutationIDChanges,
562
+ rowsPatch,
563
+ });
564
+ return Response.json({ mutations: mutationResults });
565
+ }
566
+ catch (err) {
567
+ const budgetResponse = this.writeBudgetErrorResponse(err);
568
+ if (budgetResponse)
569
+ return budgetResponse;
570
+ return Response.json({ error: err.message }, { status: 500 });
571
+ }
572
+ }
573
+ handlePull(socket, body) {
574
+ this.sendJSON(socket, [
575
+ 'pull',
576
+ {
577
+ requestID: body?.requestID || crypto.randomUUID(),
578
+ cookie: this.cookie(),
579
+ lastMutationIDChanges: {},
580
+ patch: [],
581
+ },
582
+ ]);
583
+ }
584
+ // ── SQL execution endpoints ─────────────────────────────────────────────
585
+ async handleExec(request) {
586
+ let sql = '';
587
+ const measurements = this.startWriteMeasurement(request);
588
+ try {
589
+ const body = (await request.json());
590
+ sql = body.sql;
591
+ const params = Array.isArray(body.params) ? body.params : [];
592
+ const transactionID = String(body.transactionID || body.track?.transactionID || '');
593
+ // CDC rows are drained into the durable change log before the storage
594
+ // transaction returns. Keep unrelated DDL/read calls on the fast path:
595
+ // wrapping every /exec adds ~2-5ms and materially slows large schemas.
596
+ const needsAtomicCapture = !!body.track ||
597
+ (this.cdc.active && (isSqlRowMutation(sql) || this.cdc.capturesSchemaChange(sql)));
598
+ const result = await this.withSchemaProvisioningWait(() => needsAtomicCapture
599
+ ? this.atomically(() => this.executeSQL(sql, params, body.track, transactionID || undefined))
600
+ : this.executeSQL(sql, params, undefined, transactionID || undefined));
601
+ return Response.json(measurements ? { ...result, writeMeasurements: measurements } : result);
602
+ }
603
+ catch (err) {
604
+ const budgetResponse = this.writeBudgetErrorResponse(err);
605
+ if (budgetResponse)
606
+ return budgetResponse;
607
+ const suffix = sql ? ` while executing: ${sqlErrorSnippet(sql, err.message)}` : '';
608
+ console.error(`[exec-500] ${err.message} :: SQL=${sql.slice(0, 800)}`);
609
+ return Response.json({ error: `${err.message}${suffix}` }, { status: 500 });
610
+ }
611
+ finally {
612
+ if (measurements)
613
+ this.activeWriteMeasurements = null;
614
+ }
615
+ }
616
+ /** Execute multiple statements atomically via ctx.storage.transaction() */
617
+ async handleBatch(request) {
618
+ const measurements = this.startWriteMeasurement(request);
619
+ try {
620
+ const { statements, cdcTables } = (await request.json());
621
+ const allRows = await this.withSchemaProvisioningWait(() => this.atomically(() => {
622
+ const results = [];
623
+ if (Array.isArray(cdcTables))
624
+ this.cdc.syncTables(cdcTables);
625
+ for (const statement of statements) {
626
+ const item = typeof statement === 'string' ? { sql: statement } : statement;
627
+ if (!item?.sql?.trim())
628
+ continue;
629
+ if (item.skipIfColumnExists &&
630
+ this.tableHasColumn(item.skipIfColumnExists.table, item.skipIfColumnExists.column)) {
631
+ continue;
632
+ }
633
+ if (item.skipIfColumnMissing &&
634
+ !this.tableHasColumn(item.skipIfColumnMissing.table, item.skipIfColumnMissing.column)) {
635
+ continue;
636
+ }
637
+ try {
638
+ results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track, item.transactionID || item.track?.transactionID));
639
+ }
640
+ catch (err) {
641
+ if (err instanceof WriteBudgetExceededError)
642
+ throw err;
643
+ throw new Error(`${err.message} while executing: ${sqlErrorSnippet(item.sql, err.message)}`);
644
+ }
645
+ }
646
+ return results;
647
+ }));
648
+ return Response.json({
649
+ results: allRows,
650
+ capturedChanges: allRows.reduce((total, result) => total + Number(result.capturedChanges ?? 0), 0),
651
+ ...(measurements ? { writeMeasurements: measurements } : null),
652
+ });
653
+ }
654
+ catch (err) {
655
+ const budgetResponse = this.writeBudgetErrorResponse(err);
656
+ if (budgetResponse)
657
+ return budgetResponse;
658
+ return Response.json({ error: err.message }, { status: 500 });
659
+ }
660
+ finally {
661
+ if (measurements)
662
+ this.activeWriteMeasurements = null;
663
+ }
664
+ }
665
+ // Fresh project traffic can arrive while the deploy shim is still applying
666
+ // its schema through a separate request to this same DO. Yield on SQLite's
667
+ // undefined-table error so that migration request can commit, then retry the
668
+ // read/batch. The operation that failed did not execute, and a failed batch
669
+ // transaction has rolled back, so replay is safe.
670
+ async withSchemaProvisioningWait(operation) {
671
+ const deadline = Date.now() + SCHEMA_PROVISIONING_WAIT_MS;
672
+ let delayMs = 25;
673
+ for (;;) {
674
+ try {
675
+ return await operation();
676
+ }
677
+ catch (error) {
678
+ if (!/no such table:/i.test(String(error?.message ?? error)))
679
+ throw error;
680
+ if (Date.now() >= deadline)
681
+ throw error;
682
+ await scheduler.wait(delayMs);
683
+ delayMs = Math.min(SCHEMA_PROVISIONING_MAX_DELAY_MS, delayMs * 2);
684
+ }
685
+ }
686
+ }
687
+ startWriteMeasurement(request) {
688
+ if (request.headers.get('x-orez-measure-writes') !== '1')
689
+ return null;
690
+ const measurements = [];
691
+ this.activeWriteMeasurements = measurements;
692
+ return measurements;
693
+ }
694
+ /**
695
+ * Atomic commit point for an application-SQL transaction. Promotes the
696
+ * tx's pending tracked changes into _zero_changes (allocating watermarks)
697
+ * and clears its journal (drops snapshots + manifest rows) in ONE storage
698
+ * transaction, so a DO kill can never leave a tx half-committed: either the
699
+ * manifest rows are gone (committed) or recovery rolls the tx back.
700
+ */
701
+ async handleCommitTransaction(request) {
702
+ const measurements = this.startWriteMeasurement(request);
703
+ try {
704
+ const body = (await request.json());
705
+ const transactionID = String(body.transactionID || '');
706
+ if (!transactionID)
707
+ throw new Error('missing transactionID');
708
+ const count = await this.atomically(() => {
709
+ const committed = this.commitPendingTrackedChanges(transactionID);
710
+ commitTxJournal(this.sql, transactionID);
711
+ return committed;
712
+ });
713
+ return Response.json({
714
+ ok: true,
715
+ count,
716
+ ...(measurements ? { writeMeasurements: measurements } : null),
717
+ });
718
+ }
719
+ catch (err) {
720
+ const budgetResponse = this.writeBudgetErrorResponse(err);
721
+ if (budgetResponse)
722
+ return budgetResponse;
723
+ return Response.json({ error: err.message }, { status: 500 });
724
+ }
725
+ finally {
726
+ if (measurements)
727
+ this.activeWriteMeasurements = null;
728
+ }
729
+ }
730
+ async handleSnapshotTransactionSchema(request) {
731
+ try {
732
+ const body = (await request.json());
733
+ const transactionID = String(body.transactionID || '');
734
+ if (!transactionID)
735
+ throw new Error('missing transactionID');
736
+ const owner = body.owner === undefined ? 'default' : String(body.owner);
737
+ const affectedTables = Array.isArray(body.affectedTables)
738
+ ? body.affectedTables.map(String)
739
+ : [];
740
+ await this.atomically(() => snapshotTxSchema(this.sql, transactionID, owner, affectedTables));
741
+ return Response.json({ ok: true });
742
+ }
743
+ catch (err) {
744
+ const budgetResponse = this.writeBudgetErrorResponse(err);
745
+ if (budgetResponse)
746
+ return budgetResponse;
747
+ return Response.json({ error: err.message }, { status: 500 });
748
+ }
749
+ }
750
+ async handleRollbackTransaction(request) {
751
+ const measurements = this.startWriteMeasurement(request);
752
+ try {
753
+ const body = (await request.json());
754
+ const transactionID = String(body.transactionID || '');
755
+ if (!transactionID)
756
+ throw new Error('missing transactionID');
757
+ const count = this.rollbackAtomicallyWithoutForeignKeys(() => {
758
+ this.rollbackPendingTrackedChanges(transactionID);
759
+ rollbackTxJournal(this.sql, transactionID);
760
+ return this.deletePendingTrackedChanges(transactionID);
761
+ });
762
+ this.invalidateSchemaCaches();
763
+ return Response.json({
764
+ ok: true,
765
+ count,
766
+ ...(measurements ? { writeMeasurements: measurements } : null),
767
+ });
768
+ }
769
+ catch (err) {
770
+ const budgetResponse = this.writeBudgetErrorResponse(err);
771
+ if (budgetResponse)
772
+ return budgetResponse;
773
+ return Response.json({ error: err.message }, { status: 500 });
774
+ }
775
+ finally {
776
+ if (measurements)
777
+ this.activeWriteMeasurements = null;
778
+ }
779
+ }
780
+ /**
781
+ * roll back orphaned transactions for a dead process generation. callers
782
+ * (e.g. the zero-cache embed at boot, before opening pg sessions) own the
783
+ * liveness guarantee: every journaled tx for `owner` is dead.
784
+ */
785
+ async handleRecoverTransactions(request) {
786
+ try {
787
+ const body = (await request.json().catch(() => ({})));
788
+ const owner = body.owner === undefined ? undefined : String(body.owner);
789
+ const transactionIDs = this.rollbackAtomicallyWithoutForeignKeys(() => {
790
+ const recovered = recoverTxJournal(this.sql, owner, (txID) => {
791
+ this.rollbackPendingTrackedChanges(txID);
792
+ });
793
+ for (const txID of recovered)
794
+ this.deletePendingTrackedChanges(txID);
795
+ return recovered;
796
+ });
797
+ this.invalidateSchemaCaches();
798
+ return Response.json({ ok: true, transactionIDs });
799
+ }
800
+ catch (err) {
801
+ const budgetResponse = this.writeBudgetErrorResponse(err);
802
+ if (budgetResponse)
803
+ return budgetResponse;
804
+ return Response.json({ error: err.message }, { status: 500 });
805
+ }
806
+ }
807
+ async handleChanges(request, url) {
808
+ try {
809
+ let watermark = Number(url.searchParams.get('watermark') ?? url.searchParams.get('since') ?? 0);
810
+ let limit = Number(url.searchParams.get('limit') ?? 1000);
811
+ if (request.method === 'POST') {
812
+ const body = (await request.json().catch(() => ({})));
813
+ watermark = Number(body.watermark ?? body.since ?? watermark);
814
+ limit = Number(body.limit ?? limit);
815
+ }
816
+ if (!Number.isFinite(watermark) || watermark < 0)
817
+ watermark = 0;
818
+ if (!Number.isFinite(limit) || limit <= 0)
819
+ limit = 1000;
820
+ const changeLimit = Math.trunc(Math.min(limit, 10_000));
821
+ const head = this.watermark();
822
+ const first = this.sql
823
+ .exec('SELECT MIN(watermark) AS watermark FROM _zero_changes')
824
+ .one();
825
+ const oldest = first?.watermark == null ? null : Number(first.watermark);
826
+ if (watermark < head && (oldest === null || oldest > watermark + 1)) {
827
+ return Response.json({ error: 'watermarkTooOld', watermark: head, oldestWatermark: oldest }, { status: 410 });
828
+ }
829
+ return Response.json({
830
+ watermark: head,
831
+ changes: this.readChangesSince(watermark, changeLimit),
832
+ });
833
+ }
834
+ catch (err) {
835
+ const budgetResponse = this.writeBudgetErrorResponse(err);
836
+ if (budgetResponse)
837
+ return budgetResponse;
838
+ return Response.json({ error: err.message }, { status: 500 });
839
+ }
840
+ }
841
+ async handleSnapshot(url) {
842
+ try {
843
+ const paged = url &&
844
+ ['table', 'cursor', 'limit'].some((parameter) => url.searchParams.has(parameter));
845
+ if (paged) {
846
+ const table = url.searchParams.get('table');
847
+ if (!table)
848
+ return Response.json({ error: 'paged snapshot requires a table parameter' }, { status: 400 });
849
+ const limitValue = url.searchParams.get('limit');
850
+ const limit = limitValue === null ? DEFAULT_SNAPSHOT_PAGE_ROWS : Number(limitValue);
851
+ if (!Number.isSafeInteger(limit) ||
852
+ limit <= 0 ||
853
+ limit > MAX_SNAPSHOT_PAGE_ROWS) {
854
+ return Response.json({
855
+ error: `snapshot limit must be an integer from 1 to ${MAX_SNAPSHOT_PAGE_ROWS}`,
856
+ }, { status: 400 });
857
+ }
858
+ return this.atomicallySync(() => {
859
+ this.ensureSchemaMetadataTable();
860
+ const schemaRow = this.sql
861
+ .exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', table)
862
+ .one();
863
+ if (!schemaRow?.schema_json)
864
+ return Response.json({ error: `snapshot table ${JSON.stringify(table)} is not modeled` }, { status: 400 });
865
+ const schema = JSON.parse(String(schemaRow.schema_json));
866
+ if (!Array.isArray(schema.primaryKey) ||
867
+ !schema.primaryKey.length ||
868
+ schema.primaryKey.some((column) => typeof column !== 'string' || !column)) {
869
+ throw new Error(`snapshot table ${JSON.stringify(table)} has no valid primary key`);
870
+ }
871
+ this.tableSchemas.set(table, schema);
872
+ const physicalTable = schema.physicalName || table;
873
+ const physicalPrimaryKey = schema.primaryKey.map((column) => schema.columns[column]?.serverName || column);
874
+ const cursor = url.searchParams.get('cursor');
875
+ let cursorValues = null;
876
+ if (cursor !== null) {
877
+ try {
878
+ const decoded = JSON.parse(cursor);
879
+ if (!Array.isArray(decoded) ||
880
+ decoded.length !== schema.primaryKey.length ||
881
+ decoded.some((value) => !['string', 'number', 'boolean'].includes(typeof value) ||
882
+ (typeof value === 'number' && !Number.isFinite(value)))) {
883
+ return Response.json({ error: 'snapshot cursor does not match the table primary key' }, { status: 400 });
884
+ }
885
+ cursorValues = decoded;
886
+ }
887
+ catch {
888
+ return Response.json({ error: 'snapshot cursor is invalid' }, { status: 400 });
889
+ }
890
+ }
891
+ const primaryKey = physicalPrimaryKey.map(quoteIdent);
892
+ const keyColumns = primaryKey.length === 1 ? primaryKey[0] : `(${primaryKey.join(', ')})`;
893
+ const keyParams = primaryKey.length === 1 ? '?' : `(${primaryKey.map(() => '?').join(', ')})`;
894
+ const where = cursorValues ? ` WHERE ${keyColumns} > ${keyParams}` : '';
895
+ // one look-ahead row distinguishes an exact final page from a page
896
+ // with more data without issuing an unbounded count or second read.
897
+ const page = this.sql
898
+ .exec(`SELECT * FROM ${quoteIdent(physicalTable)}${where} ORDER BY ${primaryKey.join(', ')} LIMIT ?`, ...(cursorValues ?? []), limit + 1)
899
+ .toArray();
900
+ const hasMore = page.length > limit;
901
+ const rawRows = hasMore ? page.slice(0, limit) : page;
902
+ let nextCursor = null;
903
+ if (hasMore) {
904
+ const last = rawRows[rawRows.length - 1];
905
+ const values = physicalPrimaryKey.map((column) => last[column]);
906
+ if (values.some((value) => !['string', 'number', 'boolean'].includes(typeof value) ||
907
+ (typeof value === 'number' && !Number.isFinite(value)))) {
908
+ throw new Error(`snapshot table ${JSON.stringify(table)} returned an invalid primary key`);
909
+ }
910
+ nextCursor = JSON.stringify(values);
911
+ }
912
+ return Response.json({
913
+ watermark: this.watermark(),
914
+ rows: rawRows.map((row) => this.normalizeRow(table, row)),
915
+ nextCursor,
916
+ });
917
+ });
918
+ }
919
+ return this.atomicallySync(() => {
920
+ this.ensureSchemaMetadataTable();
921
+ const names = this.sql
922
+ .exec('SELECT name FROM _zero_schema_tables ORDER BY name')
923
+ .toArray()
924
+ .map((row) => String(row.name));
925
+ const tables = {};
926
+ for (const name of names) {
927
+ const schema = this.schemaForTable(name);
928
+ const physicalName = schema?.physicalName || name;
929
+ tables[name] = this.sql
930
+ .exec(`SELECT * FROM ${quoteIdent(physicalName)}`)
931
+ .toArray()
932
+ .map((row) => this.normalizeRow(name, row));
933
+ }
934
+ return Response.json({ watermark: this.watermark(), tables });
935
+ });
936
+ }
937
+ catch (err) {
938
+ const budgetResponse = this.writeBudgetErrorResponse(err);
939
+ if (budgetResponse)
940
+ return budgetResponse;
941
+ return Response.json({ error: err.message }, { status: 500 });
942
+ }
943
+ }
944
+ /**
945
+ * Run work in a storage transaction, re-deriving every in-memory schema cache
946
+ * from SQLite if it aborts.
947
+ *
948
+ * ctx.storage.transaction() rolls the SQLite side back on throw, but the
949
+ * caches are plain fields that keep asserting state SQLite no longer has: a
950
+ * CDC table stays "registered and verified" with no trigger left on disk, so
951
+ * ensureTable short-circuits and every later write to it goes silently
952
+ * uncaptured. The readiness flags are the same class: their CREATE TABLE is
953
+ * rolled back while the flag still says the table exists.
954
+ */
955
+ async atomically(work) {
956
+ try {
957
+ return await this.ctx.storage.transaction(work);
958
+ }
959
+ catch (error) {
960
+ this.invalidateSchemaCaches();
961
+ throw error;
962
+ }
963
+ }
964
+ /**
965
+ * execute trusted subclass work in this object's SQLite transaction.
966
+ *
967
+ * the method is protected so the base public fetch surface cannot invoke it.
968
+ * every SQL cursor is consumed before an executor promise is returned.
969
+ */
970
+ async runApplicationTransaction(compileQuery, work, queryBudget) {
971
+ const execute = (sql, params = [], metadata) => {
972
+ assertApplicationTransactionSQL(sql);
973
+ return this.executeSQL(sql, [...params], applicationSqlTrack(metadata));
974
+ };
975
+ const tx = {
976
+ async exec(sql, params = [], metadata) {
977
+ const result = execute(sql, params, metadata);
978
+ return { changes: result.changes };
979
+ },
980
+ async query(sql, params = []) {
981
+ return execute(sql, params).rows;
982
+ },
983
+ async queryAst(ast, format, queryName) {
984
+ const compiled = await compileQuery(ast, format);
985
+ return executeTransactionQueryPlan(compiled, (sql, params) => execute(sql, params).rows, { queryName, budget: queryBudget });
986
+ },
987
+ };
988
+ return this.withSchemaProvisioningWait(() => this.atomically(async () => {
989
+ return work(tx);
990
+ }));
991
+ }
992
+ assertApplicationSqlSession(session) {
993
+ if (session.state !== 'active' || this.activeApplicationSqlSession !== session) {
994
+ throw new Error('application SQLite session is not active');
995
+ }
996
+ }
997
+ [APPLICATION_SQL_ACQUIRE](session) {
998
+ if (session.state === 'active' && this.activeApplicationSqlSession === session) {
999
+ return true;
1000
+ }
1001
+ if (session.state !== 'created') {
1002
+ throw new Error('application SQLite session cannot begin again');
1003
+ }
1004
+ if (this.activeApplicationSqlSession)
1005
+ return false;
1006
+ session.state = 'active';
1007
+ this.activeApplicationSqlSession = session;
1008
+ return true;
1009
+ }
1010
+ releaseApplicationSqlTurn(session) {
1011
+ this.assertApplicationSqlSession(session);
1012
+ session.state = 'closed';
1013
+ this.activeApplicationSqlSession = null;
1014
+ }
1015
+ async withApplicationSqlTurn(work, signal) {
1016
+ const session = await this.applicationSqlSession(crypto.randomUUID());
1017
+ const dispose = () => session[Symbol.dispose]();
1018
+ signal?.addEventListener('abort', dispose, { once: true });
1019
+ try {
1020
+ while (!(await session.begin())) {
1021
+ if (signal?.aborted)
1022
+ throw new Error('application SQLite request was canceled');
1023
+ await new Promise((resolve) => setTimeout(resolve, 25));
1024
+ }
1025
+ return await work();
1026
+ }
1027
+ finally {
1028
+ signal?.removeEventListener('abort', dispose);
1029
+ if (session.state === 'active')
1030
+ this.releaseApplicationSqlTurn(session);
1031
+ else if (session.state !== 'closed')
1032
+ dispose();
1033
+ }
1034
+ }
1035
+ /**
1036
+ * drive one application SQLite session from INSIDE this durable object.
1037
+ *
1038
+ * identical to the client-side session helper in application-sql.ts — same
1039
+ * ownership acquisition, same per-statement journal, same commit/rollback —
1040
+ * except the begin poll and every statement are local calls instead of RPCs.
1041
+ * the caller must already be running in this object.
1042
+ */
1043
+ async withLocalApplicationSqlSession(work) {
1044
+ const session = await this.applicationSqlSession(crypto.randomUUID());
1045
+ try {
1046
+ // bounded, unlike the client-side poll. a remote caller that goes away
1047
+ // stops polling and frees the turn; a local caller has no signal to
1048
+ // observe, so an unbounded loop would spin for the life of the object
1049
+ // every time somebody abandoned a request mid-reconcile.
1050
+ const deadline = Date.now() + APPLICATION_SQL_TURN_WAIT_MS;
1051
+ while (!(await session.begin())) {
1052
+ if (Date.now() >= deadline) {
1053
+ throw new Error('timed out acquiring the application SQLite session');
1054
+ }
1055
+ await scheduler.wait(25);
1056
+ }
1057
+ const value = await work(session);
1058
+ await session.commit();
1059
+ return value;
1060
+ }
1061
+ catch (error) {
1062
+ await session.rollback().catch(() => { });
1063
+ throw error;
1064
+ }
1065
+ finally {
1066
+ if (session.state !== 'closed')
1067
+ session[Symbol.dispose]();
1068
+ }
1069
+ }
1070
+ /**
1071
+ * an ApplicationSqlClient bound to THIS object, for subclass work that would
1072
+ * otherwise call back into itself over RPC.
1073
+ *
1074
+ * a schema migration is the motivating case: driven from a worker it costs one
1075
+ * round trip per statement, so replaying a full migration history into a fresh
1076
+ * namespace ran ~1000 sequential round trips and measured 35.9s wall against
1077
+ * 2.8s of durable-object cpu — 92% of it spent idle between statements. the
1078
+ * statements, their order and their transaction semantics are unchanged; only
1079
+ * the transport is.
1080
+ */
1081
+ applicationSqlLocalClient(namespace) {
1082
+ const run = (work) => this.withLocalApplicationSqlSession(work);
1083
+ return {
1084
+ namespace,
1085
+ query: (sql, params = []) => run((session) => session.query(sql, params)),
1086
+ exec: (sql, params = [], metadata) => run((session) => session.exec(sql, params, metadata)),
1087
+ registerTables: (tables) => run((session) => session.registerTables(tables)),
1088
+ transaction: (compileQuery, work, queryBudget) => run(async (session) => work({
1089
+ exec: (sql, params = [], metadata) => session.exec(sql, params, metadata),
1090
+ query: (sql, params = []) => session.query(sql, params),
1091
+ registerTables: (tables) => session.registerTables(tables),
1092
+ async queryAst(ast, format, queryName) {
1093
+ return session.queryPlan(await compileQuery(ast, format), queryName, queryBudget);
1094
+ },
1095
+ })),
1096
+ };
1097
+ }
1098
+ registerApplicationSqlTables(tables) {
1099
+ for (const table of tables) {
1100
+ this.cdc.ensureTable({
1101
+ physicalTableName: table.table,
1102
+ tableName: table.publicTable,
1103
+ ...(table.publish === false ? { publish: false } : null),
1104
+ });
1105
+ }
1106
+ }
1107
+ /**
1108
+ * private durable object RPC surface for the application SQLite client. the
1109
+ * disposable target exists before ownership acquisition, so waiting callers
1110
+ * hold no server state and active cancellation rolls back the transaction.
1111
+ * this method is intentionally absent from fetch().
1112
+ */
1113
+ async applicationSqlSession(sessionID) {
1114
+ if (!sessionID)
1115
+ throw new TypeError('application SQLite session id is required');
1116
+ return new ApplicationSqlSessionTarget(this, sessionID);
1117
+ }
1118
+ prepareApplicationSqlMutation(sessionID, sql) {
1119
+ if (!isSqlMutation(sql))
1120
+ return false;
1121
+ if (!isSqlRowMutation(sql)) {
1122
+ const targets = schemaChangeTargets(sql);
1123
+ if (/^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(sql) &&
1124
+ targets.length > 0 &&
1125
+ targets.every((table) => this.tableExists(table))) {
1126
+ return false;
1127
+ }
1128
+ beginTxJournal(this.sql, sessionID, 'application');
1129
+ snapshotTxSchema(this.sql, sessionID, 'application', targets);
1130
+ return true;
1131
+ }
1132
+ beginTxJournal(this.sql, sessionID, 'application');
1133
+ return true;
1134
+ }
1135
+ async [APPLICATION_SQL_QUERY](session, sql, params = []) {
1136
+ this.assertApplicationSqlSession(session);
1137
+ return this.atomically(() => {
1138
+ if (this.prepareApplicationSqlMutation(session.sessionID, sql)) {
1139
+ session.mutated = true;
1140
+ }
1141
+ return this.executeSQL(sql, [...params], undefined, session.sessionID).rows;
1142
+ });
1143
+ }
1144
+ async [APPLICATION_SQL_EXEC](session, sql, params = [], metadata) {
1145
+ this.assertApplicationSqlSession(session);
1146
+ return this.atomically(() => {
1147
+ if (this.prepareApplicationSqlMutation(session.sessionID, sql)) {
1148
+ session.mutated = true;
1149
+ }
1150
+ const result = this.executeSQL(sql, [...params], applicationSqlTrack(metadata), session.sessionID);
1151
+ if (result.changes > 0)
1152
+ session.changed = true;
1153
+ return { changes: result.changes };
1154
+ });
1155
+ }
1156
+ async [APPLICATION_SQL_QUERY_PLAN](session, plan, queryName, queryBudget) {
1157
+ this.assertApplicationSqlSession(session);
1158
+ return this.atomically(() => executeTransactionQueryPlan(plan, (sql, params) => this.executeSQL(sql, params, undefined, session.sessionID).rows, { queryName, budget: queryBudget }));
1159
+ }
1160
+ async [APPLICATION_SQL_REGISTER_TABLES](session, tables) {
1161
+ this.assertApplicationSqlSession(session);
1162
+ await this.atomically(() => this.registerApplicationSqlTables(tables));
1163
+ }
1164
+ async [APPLICATION_SQL_COMMIT](session) {
1165
+ this.assertApplicationSqlSession(session);
1166
+ try {
1167
+ if (session.mutated) {
1168
+ await this.atomically(() => {
1169
+ this.commitPendingTrackedChanges(session.sessionID);
1170
+ commitTxJournal(this.sql, session.sessionID);
1171
+ });
1172
+ }
1173
+ }
1174
+ finally {
1175
+ this.releaseApplicationSqlTurn(session);
1176
+ }
1177
+ this.applicationSqlDidCommit(session.changed, session.mutated);
1178
+ }
1179
+ async [APPLICATION_SQL_ROLLBACK](session) {
1180
+ if (session.state === 'created') {
1181
+ session.state = 'closed';
1182
+ return;
1183
+ }
1184
+ this.assertApplicationSqlSession(session);
1185
+ try {
1186
+ if (session.mutated) {
1187
+ this.rollbackAtomicallyWithoutForeignKeys(() => {
1188
+ this.rollbackPendingTrackedChanges(session.sessionID);
1189
+ rollbackTxJournal(this.sql, session.sessionID);
1190
+ this.deletePendingTrackedChanges(session.sessionID);
1191
+ });
1192
+ this.invalidateSchemaCaches();
1193
+ }
1194
+ }
1195
+ finally {
1196
+ this.releaseApplicationSqlTurn(session);
1197
+ }
1198
+ }
1199
+ [APPLICATION_SQL_DISPOSE](session) {
1200
+ if (session.state === 'closed')
1201
+ return;
1202
+ if (session.state === 'created') {
1203
+ session.state = 'closed';
1204
+ return;
1205
+ }
1206
+ this.assertApplicationSqlSession(session);
1207
+ try {
1208
+ if (session.mutated) {
1209
+ this.rollbackAtomicallyWithoutForeignKeys(() => {
1210
+ this.rollbackPendingTrackedChanges(session.sessionID);
1211
+ rollbackTxJournal(this.sql, session.sessionID);
1212
+ this.deletePendingTrackedChanges(session.sessionID);
1213
+ });
1214
+ this.invalidateSchemaCaches();
1215
+ }
1216
+ }
1217
+ finally {
1218
+ this.releaseApplicationSqlTurn(session);
1219
+ }
1220
+ }
1221
+ atomicallySync(work) {
1222
+ try {
1223
+ return this.ctx.storage.transactionSync(work);
1224
+ }
1225
+ catch (error) {
1226
+ this.invalidateSchemaCaches();
1227
+ throw error;
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Run journal rollback/recovery with foreign-key enforcement off.
1232
+ *
1233
+ * Rollback restores pre-transaction images verbatim, so the target state is
1234
+ * the pre-tx reality whatever its referential health. Enforcement is also
1235
+ * fatal here: workerd always runs with foreign_keys on, and in a namespace
1236
+ * whose parent table is missing (an interrupted table rebuild) EVERY
1237
+ * statement on a referencing table fails at compile with "no such table:
1238
+ * main.<parent>". That made recovery unable to ever finish, and because the
1239
+ * storage transaction rolled the cleanup back while Cloudflare still billed
1240
+ * the rows written, every wake re-ran the same doomed replay forever (the
1241
+ * 2026-07-25 rows-written runaway). The pragma cannot change inside a
1242
+ * transaction, so it brackets one synchronous transaction; the whole helper
1243
+ * is synchronous, so no other event can execute while enforcement is off.
1244
+ * The cursors must be consumed: workerd executes a statement only as its
1245
+ * cursor is read, so a discarded PRAGMA cursor silently never runs.
1246
+ */
1247
+ rollbackAtomicallyWithoutForeignKeys(work) {
1248
+ this.sql.exec('PRAGMA foreign_keys = OFF').toArray();
1249
+ try {
1250
+ return this.atomicallySync(work);
1251
+ }
1252
+ finally {
1253
+ this.restoreForeignKeyEnforcement();
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Measured under workerd: only the first foreign_keys change in an event
1258
+ * applies, so the OFF above latches and an inline reset here is silently
1259
+ * ignored — the object would then serve every later write unenforced.
1260
+ * Verify, and when the inline reset did not stick, re-run it from
1261
+ * blockConcurrencyWhile: the runtime defers that until the in-flight
1262
+ * storage work is done, the same seam persistWriteBudgetTrip relies on.
1263
+ */
1264
+ restoreForeignKeyEnforcement() {
1265
+ this.sql.exec('PRAGMA foreign_keys = ON').toArray();
1266
+ const state = this.sql.exec('PRAGMA foreign_keys').one();
1267
+ if (Number(state?.foreign_keys ?? 0) === 1)
1268
+ return;
1269
+ void this.ctx
1270
+ .blockConcurrencyWhile(async () => {
1271
+ this.sql.exec('PRAGMA foreign_keys = ON').toArray();
1272
+ })
1273
+ .catch((error) => {
1274
+ console.error(JSON.stringify({
1275
+ event: 'orez_do_foreign_keys_restore_failed',
1276
+ message: error instanceof Error ? error.message : String(error),
1277
+ }));
1278
+ });
1279
+ }
1280
+ invalidateSchemaCaches() {
1281
+ this.watermarks.invalidateCache();
1282
+ this.pendingChangesSchemaReady = false;
1283
+ // Reload is intentionally last because corrupt persisted CDC metadata must
1284
+ // throw (fail closed), without preventing the other caches from invalidating.
1285
+ this.cdc.reload();
1286
+ }
1287
+ // sync (storage.transaction-safe) column presence check for the /batch
1288
+ // skipIfColumnExists/skipIfColumnMissing conditions. a missing table reads
1289
+ // as "no columns", which makes ADD COLUMN skips behave like pg's
1290
+ // IF NOT EXISTS on a table the same batch is about to create.
1291
+ tableHasColumn(table, column) {
1292
+ try {
1293
+ const cursor = this.sql.exec('SELECT 1 FROM pragma_table_info(?) WHERE name = ? LIMIT 1', table, column);
1294
+ return this.cursorRows(cursor).length > 0;
1295
+ }
1296
+ catch {
1297
+ return false;
1298
+ }
1299
+ }
1300
+ executeSQL(sql, params = [], track, transactionID) {
1301
+ let capturesTrackedTable = false;
1302
+ if (track?.physicalTableName) {
1303
+ capturesTrackedTable = this.cdc.ensureTable({
1304
+ physicalTableName: track.physicalTableName,
1305
+ tableName: track.tableName,
1306
+ ...(track.publish === false ? { publish: false } : null),
1307
+ ...(track.rowColumns?.length ? { columns: track.rowColumns } : null),
1308
+ });
1309
+ }
1310
+ else if (track) {
1311
+ capturesTrackedTable = this.cdc.capturesTable(track.tableName);
1312
+ }
1313
+ // SQLite decides whether an INSERT ... ON CONFLICT write inserted or
1314
+ // updated. Only the installed trigger sees that result. Falling back to a
1315
+ // caller-declared operation would publish a false change shape.
1316
+ if (track?.operation === 'UPSERT' && !capturesTrackedTable) {
1317
+ throw new Error(`upsert requires CDC registration for ${track.tableName}`);
1318
+ }
1319
+ // The transaction owner marked this table row-journaled, expecting the DO to
1320
+ // capture before/after images for it. When it cannot, that marker promises
1321
+ // a rollback nothing can perform, so take the table snapshot the journal
1322
+ // would otherwise have taken. It has to happen before the DML, while the
1323
+ // table still holds its pre-transaction contents.
1324
+ const trackedTransactionID = track ? transactionID || track.transactionID : undefined;
1325
+ let snapshotsOwnStatement = false;
1326
+ if (track && !capturesTrackedTable && trackedTransactionID) {
1327
+ const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
1328
+ if (this.tableExists(physicalTableName)) {
1329
+ upgradeToTableSnapshot(this.sql, trackedTransactionID, physicalTableName);
1330
+ }
1331
+ }
1332
+ if (track && trackedTransactionID) {
1333
+ const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
1334
+ snapshotsOwnStatement = snapshotSideEffectWriteTables(this.sql, trackedTransactionID, physicalTableName, track.operation);
1335
+ }
1336
+ const suspendedCdc = this.cdc.beginSchemaChange(sql);
1337
+ let cursor;
1338
+ try {
1339
+ cursor = this.sql.exec(sql, ...params);
1340
+ }
1341
+ catch (error) {
1342
+ // Restore capture against the unchanged schema before propagating a DDL
1343
+ // failure. In normal /exec and /batch paths this is also protected by the
1344
+ // surrounding storage transaction.
1345
+ this.cdc.finishSchemaChange(suspendedCdc);
1346
+ throw error;
1347
+ }
1348
+ this.cdc.finishSchemaChange(suspendedCdc);
1349
+ const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : [];
1350
+ const rows = this.cursorRows(cursor, columns);
1351
+ const { mutation, rowMutation } = classifySql(sql);
1352
+ const changes = rowMutation
1353
+ ? Number(this.sql.exec('SELECT changes() AS changes').one()?.changes ?? 0)
1354
+ : 0;
1355
+ if (mutation)
1356
+ this.writeBudget.recordLogical(rows.length);
1357
+ if (mutation && !rowMutation)
1358
+ this.cdc.invalidateSchema();
1359
+ const captured = track || (this.cdc.active && rowMutation) ? this.cdc.drain() : [];
1360
+ for (const change of captured) {
1361
+ this.appendCapturedChange(change, transactionID || track?.transactionID, !snapshotsOwnStatement);
1362
+ }
1363
+ // Backward compatibility for callers that have not yet supplied the
1364
+ // physical SQLite table identity needed to install a trigger. Once a
1365
+ // table is registered, trigger capture is the sole source of truth and
1366
+ // includes arbitrary business-trigger side effects.
1367
+ //
1368
+ // These rows carry no before-image, so they only feed the changefeed. Their
1369
+ // rollback is owned by the table snapshot taken above, and `undoable: false`
1370
+ // keeps the row-undo pass from trying to restore them from a wire image that
1371
+ // cannot round-trip a blob or an int64.
1372
+ if (track && !capturesTrackedTable) {
1373
+ const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
1374
+ for (const row of rows) {
1375
+ const trackedRow = trackedChangeRow(row, track);
1376
+ const isDelete = track.operation === 'DELETE';
1377
+ this.appendTrackedChange({
1378
+ tableName: track.tableName,
1379
+ op: isDelete ? 'DELETE' : track.operation,
1380
+ rowData: isDelete ? null : trackedRow,
1381
+ oldData: isDelete ? trackedRow : null,
1382
+ transactionID: transactionID || track.transactionID,
1383
+ physicalTableName,
1384
+ publish: track.publish !== false,
1385
+ undoable: false,
1386
+ });
1387
+ }
1388
+ if (track.publish !== false)
1389
+ this.appendDerivedTrackedChanges(track, rows);
1390
+ }
1391
+ const publishedCaptured = captured.filter((change) => change.publish !== false).length;
1392
+ if (!track)
1393
+ return { rows, columns, changes, capturedChanges: publishedCaptured };
1394
+ return {
1395
+ rows: track.returnRows ? rows : [],
1396
+ columns: track.returnRows ? columns : [],
1397
+ changes,
1398
+ affectedRows: rows.length,
1399
+ capturedChanges: publishedCaptured ||
1400
+ (capturesTrackedTable || track.publish === false ? 0 : rows.length),
1401
+ };
1402
+ }
1403
+ cursorRows(cursor, columns) {
1404
+ const cols = Array.isArray(columns) && columns.length > 0 ? columns : null;
1405
+ return cursor.toArray().map((row) => {
1406
+ const obj = {};
1407
+ if (cols) {
1408
+ // include EVERY selected column, even SQL NULLs the DO cursor omits from
1409
+ // the row object — pg/drizzle consumers index results positionally, so a
1410
+ // dropped null column shifts every later value (e.g. trailing nullable
1411
+ // timestamps read back undefined and crash the type decoder).
1412
+ for (const k of cols)
1413
+ obj[k] = k in row ? row[k] : null;
1414
+ }
1415
+ else {
1416
+ for (const k of Object.keys(row))
1417
+ obj[k] = row[k];
1418
+ }
1419
+ return obj;
1420
+ });
1421
+ }
1422
+ appendDerivedTrackedChanges(track, rows) {
1423
+ if (!rows.length)
1424
+ return;
1425
+ const table = stripPublicPrefix(track.tableName);
1426
+ if (table !== 'message')
1427
+ return;
1428
+ const channelIds = new Set();
1429
+ const threadIds = new Set();
1430
+ for (const row of rows) {
1431
+ const channelId = String(row.channelId || '');
1432
+ const threadId = String(row.threadId || '');
1433
+ if (this.messageRowUpdatesChannelLatestOrder(row) && channelId) {
1434
+ channelIds.add(channelId);
1435
+ }
1436
+ if (this.messageRowUpdatesThreadReplyCount(row) && threadId) {
1437
+ threadIds.add(threadId);
1438
+ }
1439
+ }
1440
+ this.appendRowsAsUpdates('public.channel', 'channel', 'id', channelIds, track.transactionID);
1441
+ this.appendRowsAsUpdates('public.thread', 'thread', 'id', threadIds, track.transactionID);
1442
+ }
1443
+ appendRowsAsUpdates(publicTableName, sqliteTableName, keyColumn, keys, transactionID) {
1444
+ if (keys.size === 0)
1445
+ return;
1446
+ const values = [...keys];
1447
+ const placeholders = values.map(() => '?').join(', ');
1448
+ const rows = this.sql
1449
+ .exec(`SELECT * FROM ${quoteIdent(sqliteTableName)} WHERE ${quoteIdent(keyColumn)} IN (${placeholders})`, ...values)
1450
+ .toArray();
1451
+ // Derived notifications: the rows were written by business triggers and are
1452
+ // captured for the changefeed only. They carry no physical table name, so
1453
+ // the row-undo pass never sees them, and their real writes are rolled back
1454
+ // by whichever journal entry owns the table.
1455
+ for (const row of rows) {
1456
+ this.appendTrackedChange({
1457
+ tableName: publicTableName,
1458
+ op: 'UPDATE',
1459
+ rowData: row,
1460
+ oldData: null,
1461
+ transactionID,
1462
+ undoable: false,
1463
+ });
1464
+ }
1465
+ }
1466
+ messageRowUpdatesChannelLatestOrder(row) {
1467
+ return (row.type !== 'draft' &&
1468
+ row.type !== 'hidden' &&
1469
+ !row.deleted &&
1470
+ !row.isThreadReply &&
1471
+ row.order !== null &&
1472
+ row.order !== undefined);
1473
+ }
1474
+ messageRowUpdatesThreadReplyCount(row) {
1475
+ return !!row.threadId && row.type !== 'draft' && !row.deleted && row.isThreadReply;
1476
+ }
1477
+ // ── CRUD operations ──────────────────────────────────────────────────────
1478
+ applyMutation(mutation) {
1479
+ if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
1480
+ return this.applyCrudMutation(mutation);
1481
+ }
1482
+ if (mutation.name === '_zero_cleanupResults')
1483
+ return {};
1484
+ if (mutation.type === 'custom')
1485
+ return this.applyTableMutation(mutation);
1486
+ return {
1487
+ error: 'app',
1488
+ message: `unsupported mutation ${mutation.type}:${mutation.name}`,
1489
+ };
1490
+ }
1491
+ applyTableMutation(mutation) {
1492
+ const [tableName, action] = this.tableActionFromMutationName(mutation.name);
1493
+ if (!tableName || !action)
1494
+ return { error: 'app', message: `invalid mutation name ${mutation.name}` };
1495
+ if (!this.tableExists(tableName))
1496
+ return { error: 'app', message: `unknown table ${tableName}` };
1497
+ const value = (mutation.args[0] || {});
1498
+ const primaryKey = this.primaryKeyForTable(tableName, []);
1499
+ if (action === 'insert')
1500
+ this.insertRow(tableName, value, primaryKey);
1501
+ else if (action === 'upsert')
1502
+ this.upsertRow(tableName, value, primaryKey);
1503
+ else if (action === 'delete')
1504
+ this.deleteRow(tableName, value, primaryKey);
1505
+ else
1506
+ this.updateRow(tableName, value, primaryKey);
1507
+ return {};
1508
+ }
1509
+ tableActionFromMutationName(name) {
1510
+ if (name.includes('|'))
1511
+ return name.split('|', 2);
1512
+ return name.split('.', 2);
1513
+ }
1514
+ tableNameFromOperationName(name) {
1515
+ if (!name)
1516
+ return null;
1517
+ return name.split(/[.|]/, 1)[0] || null;
1518
+ }
1519
+ applyCrudMutation(mutation) {
1520
+ const arg = mutation.args[0];
1521
+ const ops = Array.isArray(arg?.ops) ? arg.ops : [];
1522
+ for (const crud of ops) {
1523
+ if (!crud?.tableName)
1524
+ return { error: 'app', message: 'invalid crud mutation' };
1525
+ if (!this.tableExists(crud.tableName))
1526
+ return { error: 'app', message: `unknown table ${crud.tableName}` };
1527
+ const value = crud.value || {};
1528
+ const primaryKey = this.primaryKeyForTable(crud.tableName, crud.primaryKey || []);
1529
+ if (crud.op === 'insert')
1530
+ this.insertRow(crud.tableName, value, primaryKey);
1531
+ else if (crud.op === 'upsert')
1532
+ this.upsertRow(crud.tableName, value, primaryKey);
1533
+ else if (crud.op === 'update')
1534
+ this.updateRow(crud.tableName, value, primaryKey);
1535
+ else if (crud.op === 'delete')
1536
+ this.deleteRow(crud.tableName, value, primaryKey);
1537
+ }
1538
+ return {};
1539
+ }
1540
+ insertRow(tn, value, pk) {
1541
+ if (this.readRowByPrimaryKey(tn, value, pk))
1542
+ return;
1543
+ const row = this.storageRow(tn, value, true);
1544
+ const cols = Object.keys(row);
1545
+ if (!cols.length)
1546
+ return;
1547
+ const qc = cols.map((c) => quoteIdent(c)).join(', ');
1548
+ const ph = cols.map(() => '?').join(', ');
1549
+ this.sql.exec(`INSERT INTO ${quoteIdent(tn)} (${qc}) VALUES (${ph})`, ...cols.map((c) => row[c]));
1550
+ this.writeBudget.recordLogical(1);
1551
+ const next = this.readRowByPrimaryKey(tn, value, pk) || this.normalizeRow(tn, row);
1552
+ this.appendChange(tn, 'INSERT', next, null);
1553
+ }
1554
+ upsertRow(tn, value, pk) {
1555
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
1556
+ if (existing) {
1557
+ this.updateRow(tn, value, pk);
1558
+ return;
1559
+ }
1560
+ this.insertRow(tn, value, pk);
1561
+ }
1562
+ updateRow(tn, value, pk) {
1563
+ if (!pk.length)
1564
+ return;
1565
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
1566
+ if (!existing)
1567
+ return;
1568
+ const nk = Object.keys(value).filter((c) => !pk.includes(c));
1569
+ if (!nk.length)
1570
+ return;
1571
+ const storage = this.storageRow(tn, value, false);
1572
+ this.sql.exec(`UPDATE ${quoteIdent(tn)} SET ${nk.map((c) => `${quoteIdent(c)} = ?`).join(', ')} WHERE ${this.primaryKeyWhere(pk)}`, ...nk.map((c) => storage[c]), ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
1573
+ this.writeBudget.recordLogical(1);
1574
+ const next = this.readRowByPrimaryKey(tn, value, pk);
1575
+ if (next)
1576
+ this.appendChange(tn, 'UPDATE', next, existing);
1577
+ }
1578
+ deleteRow(tn, value, pk) {
1579
+ if (!pk.length)
1580
+ return;
1581
+ const existing = this.readRowByPrimaryKey(tn, value, pk);
1582
+ if (!existing)
1583
+ return;
1584
+ this.sql.exec(`DELETE FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
1585
+ this.writeBudget.recordLogical(1);
1586
+ this.appendChange(tn, 'DELETE', null, existing);
1587
+ }
1588
+ appendChange(tn, op, rowData, oldData) {
1589
+ this.appendTrackedChange({ tableName: tn, op, rowData, oldData });
1590
+ }
1591
+ /** Record a trigger-captured change, before-images and row identity included. */
1592
+ appendCapturedChange(change, transactionID, undoable = true) {
1593
+ this.appendTrackedChange({
1594
+ tableName: change.tableName,
1595
+ op: change.op,
1596
+ rowData: change.rowData,
1597
+ oldData: change.oldData,
1598
+ transactionID,
1599
+ physicalTableName: change.physicalTableName,
1600
+ publish: change.publish !== false,
1601
+ rowJournal: change.rowJournal,
1602
+ oldJournal: change.oldJournal,
1603
+ newRowid: change.newRowid,
1604
+ oldRowid: change.oldRowid,
1605
+ undoable,
1606
+ });
1607
+ }
1608
+ appendTrackedChange(change) {
1609
+ const publish = change.publish !== false;
1610
+ if (!publish && !change.transactionID)
1611
+ return;
1612
+ if (change.transactionID) {
1613
+ this.ensurePendingTrackedChangesTable();
1614
+ appendPendingChange(this.sql, {
1615
+ transactionID: change.transactionID,
1616
+ physicalTableName: change.physicalTableName,
1617
+ tableName: change.tableName,
1618
+ publish,
1619
+ op: change.op,
1620
+ rowData: change.rowData,
1621
+ oldData: change.oldData,
1622
+ rowJournal: change.rowJournal ?? null,
1623
+ oldJournal: change.oldJournal ?? null,
1624
+ newRowid: change.newRowid ?? null,
1625
+ oldRowid: change.oldRowid ?? null,
1626
+ undoable: change.undoable === true,
1627
+ });
1628
+ return;
1629
+ }
1630
+ this.appendCommittedTrackedChange(change.tableName, change.op, change.rowData, change.oldData);
1631
+ }
1632
+ appendCommittedTrackedChange(tableName, op, rowData, oldData) {
1633
+ this.watermarks.ensureTables();
1634
+ const watermark = this.watermarks.next();
1635
+ this.sql.exec('INSERT INTO _zero_changes (watermark, table_name, op, row_data, old_data) VALUES (?, ?, ?, ?, ?)', watermark, tableName, op, rowData ? JSON.stringify(rowData) : null, oldData ? JSON.stringify(oldData) : null);
1636
+ this.watermarks.mark(watermark);
1637
+ }
1638
+ ensurePendingTrackedChangesTable() {
1639
+ if (this.pendingChangesSchemaReady)
1640
+ return;
1641
+ ensurePendingChangesTable(this.sql);
1642
+ this.pendingChangesSchemaReady = true;
1643
+ }
1644
+ rollbackPendingTrackedChanges(transactionID) {
1645
+ this.ensurePendingTrackedChangesTable();
1646
+ return rollbackPendingChanges(this.sql, transactionID);
1647
+ }
1648
+ commitPendingTrackedChanges(transactionID) {
1649
+ this.ensurePendingTrackedChangesTable();
1650
+ this.watermarks.ensureTables();
1651
+ const rows = this.sql
1652
+ .exec(`INSERT INTO _zero_changes (table_name, op, row_data, old_data)
1653
+ SELECT table_name, op, row_data, old_data
1654
+ FROM _zero_pending_changes
1655
+ WHERE transaction_id = ? AND publish != 0
1656
+ ORDER BY id
1657
+ RETURNING watermark`, transactionID)
1658
+ .toArray();
1659
+ let watermark = 0;
1660
+ for (const row of rows)
1661
+ watermark = Math.max(watermark, Number(row.watermark ?? 0));
1662
+ if (watermark > 0) {
1663
+ this.watermarks.mark(watermark);
1664
+ }
1665
+ this.deletePendingTrackedChanges(transactionID);
1666
+ return rows.length;
1667
+ }
1668
+ deletePendingTrackedChanges(transactionID) {
1669
+ this.ensurePendingTrackedChangesTable();
1670
+ return deletePendingChanges(this.sql, transactionID);
1671
+ }
1672
+ readChangesSince(watermark, limit) {
1673
+ this.watermarks.ensureTables();
1674
+ const statement = 'SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark' +
1675
+ (limit === undefined ? '' : ' LIMIT ?');
1676
+ const params = limit === undefined ? [watermark] : [watermark, limit];
1677
+ return this.sql
1678
+ .exec(statement, ...params)
1679
+ .toArray()
1680
+ .map((row) => {
1681
+ const tableName = String(row.table_name);
1682
+ const rowData = row.row_data ? JSON.parse(String(row.row_data)) : null;
1683
+ const oldData = row.old_data ? JSON.parse(String(row.old_data)) : null;
1684
+ return {
1685
+ watermark: Number(row.watermark),
1686
+ tableName,
1687
+ op: String(row.op),
1688
+ rowData: rowData ? this.normalizeRow(tableName, rowData) : null,
1689
+ oldData: oldData ? this.normalizeRow(tableName, oldData) : null,
1690
+ };
1691
+ });
1692
+ }
1693
+ watermark() {
1694
+ return this.watermarks.current();
1695
+ }
1696
+ ensureSchemaTables(clientSchema) {
1697
+ this.ensureSchemaMetadataTable();
1698
+ for (const [name, def] of Object.entries(clientSchema.tables)) {
1699
+ this.tableSchemas.set(name, def);
1700
+ this.createSchemaTable(name, def);
1701
+ this.ensureSchemaColumns(name, def);
1702
+ this.sql.exec('INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)', name, JSON.stringify(def));
1703
+ this.schemaTables.add(name);
1704
+ }
1705
+ }
1706
+ createSchemaTable(name, def) {
1707
+ const pk = def.primaryKey.map((c) => quoteIdent(c));
1708
+ const pkClause = pk.length ? `, PRIMARY KEY (${pk.join(', ')})` : '';
1709
+ const colDefs = Object.entries(def.columns).map(([cn, cd]) => `${quoteIdent(cn)} ${sqliteTypeForSchemaColumn(cd.type)}`);
1710
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (${colDefs.join(', ')}${pkClause})`);
1711
+ }
1712
+ ensureSchemaColumns(name, def) {
1713
+ const existing = this.columnNamesForTable(name);
1714
+ for (const [columnName, column] of Object.entries(def.columns)) {
1715
+ if (existing.has(columnName))
1716
+ continue;
1717
+ this.sql.exec(`ALTER TABLE ${quoteIdent(name)} ADD COLUMN ${quoteIdent(columnName)} ${sqliteTypeForSchemaColumn(column.type)}`);
1718
+ }
1719
+ }
1720
+ columnNamesForTable(name) {
1721
+ try {
1722
+ return new Set(this.sql
1723
+ .exec(`PRAGMA table_info(${quoteIdent(name)})`)
1724
+ .toArray()
1725
+ .map((row) => String(row.name)));
1726
+ }
1727
+ catch {
1728
+ return new Set();
1729
+ }
1730
+ }
1731
+ ensureSchemaMetadataTable() {
1732
+ this.sql.exec('CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)');
1733
+ }
1734
+ schemaForTable(tableName) {
1735
+ const schemaTableName = stripPublicPrefix(tableName);
1736
+ const tableSchemas = (this.tableSchemas ??= new Map());
1737
+ const cached = tableSchemas.get(schemaTableName);
1738
+ if (cached)
1739
+ return cached;
1740
+ try {
1741
+ this.ensureSchemaMetadataTable();
1742
+ const row = this.sql
1743
+ .exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', schemaTableName)
1744
+ .one();
1745
+ if (!row?.schema_json)
1746
+ return undefined;
1747
+ const schema = JSON.parse(String(row.schema_json));
1748
+ tableSchemas.set(schemaTableName, schema);
1749
+ return schema;
1750
+ }
1751
+ catch {
1752
+ return undefined;
1753
+ }
1754
+ }
1755
+ tableExists(n) {
1756
+ try {
1757
+ return !!this.sql
1758
+ .exec("SELECT name FROM sqlite_master WHERE type='table' AND name=?", n)
1759
+ .one();
1760
+ }
1761
+ catch {
1762
+ return false;
1763
+ }
1764
+ }
1765
+ readAllRows(tn) {
1766
+ return this.sql
1767
+ .exec(`SELECT * FROM ${quoteIdent(tn)}`)
1768
+ .toArray()
1769
+ .map((row) => this.normalizeRow(tn, row));
1770
+ }
1771
+ readRowByPrimaryKey(tn, value, pk) {
1772
+ if (!pk.length)
1773
+ return null;
1774
+ try {
1775
+ const row = this.sql
1776
+ .exec(`SELECT * FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])))
1777
+ .one();
1778
+ return row ? this.normalizeRow(tn, row) : null;
1779
+ }
1780
+ catch {
1781
+ return null;
1782
+ }
1783
+ }
1784
+ primaryKeyWhere(pk) {
1785
+ return pk.map((c) => `${quoteIdent(c)} = ?`).join(' AND ');
1786
+ }
1787
+ primaryKeyForTable(tn, fallback) {
1788
+ const schema = this.schemaForTable(tn);
1789
+ if (schema?.primaryKey?.length)
1790
+ return schema.primaryKey;
1791
+ return fallback;
1792
+ }
1793
+ storageRow(tn, value, includeMissingSchemaColumns) {
1794
+ const schema = this.schemaForTable(tn);
1795
+ const row = {};
1796
+ if (schema && includeMissingSchemaColumns) {
1797
+ for (const column of Object.keys(schema.columns))
1798
+ row[column] = this.storageColumnValue(tn, column, value[column] ?? null);
1799
+ }
1800
+ for (const column of Object.keys(value)) {
1801
+ if (value[column] !== undefined)
1802
+ row[column] = this.storageColumnValue(tn, column, value[column]);
1803
+ }
1804
+ return row;
1805
+ }
1806
+ storageColumnValue(tn, column, value) {
1807
+ if (value === undefined || value === null)
1808
+ return null;
1809
+ const schema = this.schemaForTable(tn);
1810
+ const type = schema?.columns?.[column]?.type ??
1811
+ Object.values(schema?.columns ?? {}).find((candidate) => candidate.serverName === column)?.type;
1812
+ if (type === 'boolean')
1813
+ return value ? 1 : 0;
1814
+ if (type === 'json')
1815
+ return typeof value === 'string' ? value : JSON.stringify(value);
1816
+ if (type === 'number')
1817
+ return Number(value);
1818
+ if (type === 'bigint')
1819
+ return String(value);
1820
+ return value;
1821
+ }
1822
+ normalizeRow(tn, row) {
1823
+ const schema = this.schemaForTable(tn);
1824
+ const normalized = {};
1825
+ for (const key of Object.keys(row)) {
1826
+ const type = schema?.columns?.[key]?.type ??
1827
+ Object.values(schema?.columns ?? {}).find((candidate) => candidate.serverName === key)?.type;
1828
+ const value = row[key];
1829
+ if (value === null || value === undefined) {
1830
+ normalized[key] = null;
1831
+ }
1832
+ else if (type === 'boolean') {
1833
+ normalized[key] =
1834
+ value === true || value === 1 || value === '1' || value === 'true';
1835
+ }
1836
+ else if (type === 'number') {
1837
+ // timestamp/timestamptz columns are declared `number` in the zero
1838
+ // schema but may be stored as timestamp text (for example
1839
+ // "2026-07-11 13:34:46.000+00"). Coercing
1840
+ // that text with Number() yields NaN, which JSON serializes as null and
1841
+ // silently wipes every timestamp reaching the sync-cf-host snapshot
1842
+ // feed. Forward a non-numeric value untouched so the engine's
1843
+ // timestamp_text_to_epoch_ms decodes it, matching the /changes feed
1844
+ // which forwards the raw text.
1845
+ const numeric = Number(value);
1846
+ normalized[key] = Number.isFinite(numeric) ? numeric : value;
1847
+ }
1848
+ else if (type === 'json' && typeof value === 'string') {
1849
+ try {
1850
+ normalized[key] = JSON.parse(value);
1851
+ }
1852
+ catch {
1853
+ normalized[key] = value;
1854
+ }
1855
+ }
1856
+ else {
1857
+ normalized[key] = value;
1858
+ }
1859
+ }
1860
+ return normalized;
1861
+ }
1862
+ sendSyncPoke(socket, attachment, part) {
1863
+ const cookie = this.nextCookie();
1864
+ const pokeID = crypto.randomUUID();
1865
+ this.sendJSON(socket, [
1866
+ 'pokeStart',
1867
+ {
1868
+ pokeID,
1869
+ baseCookie: attachment.cookie,
1870
+ schemaVersions: {
1871
+ minSupportedVersion: SCHEMA_VERSION,
1872
+ maxSupportedVersion: SCHEMA_VERSION,
1873
+ },
1874
+ timestamp: Date.now(),
1875
+ },
1876
+ ]);
1877
+ this.sendJSON(socket, ['pokePart', { pokeID, ...part }]);
1878
+ this.sendJSON(socket, ['pokeEnd', { pokeID, cookie }]);
1879
+ const nextAttachment = { ...attachment, cookie };
1880
+ socket.serializeAttachment(nextAttachment);
1881
+ return nextAttachment;
1882
+ }
1883
+ broadcastPoke(clientGroupID, part) {
1884
+ for (const socket of this.ctx.getWebSockets()) {
1885
+ const ws = socket;
1886
+ const attachment = this.readSocketAttachment(ws);
1887
+ if (!attachment)
1888
+ continue;
1889
+ if (attachment.clientGroupID !== clientGroupID)
1890
+ continue;
1891
+ this.sendSyncPoke(ws, attachment, part);
1892
+ }
1893
+ }
1894
+ broadcastMutationPoke(sourceAttachment, part) {
1895
+ const rowsPatch = part.rowsPatch || [];
1896
+ const changedTables = new Set(rowsPatch
1897
+ .map((op) => op?.tableName)
1898
+ .filter((tableName) => !!tableName));
1899
+ const hasLastMutationIDChanges = Object.keys(part.lastMutationIDChanges || {}).length > 0;
1900
+ for (const socket of this.ctx.getWebSockets()) {
1901
+ const ws = socket;
1902
+ const attachment = this.readSocketAttachment(ws);
1903
+ if (!attachment)
1904
+ continue;
1905
+ if (attachment.userID !== sourceAttachment.userID)
1906
+ continue;
1907
+ const isSourceClientGroup = attachment.clientGroupID === sourceAttachment.clientGroupID;
1908
+ const wantsChangedRows = changedTables.size > 0 &&
1909
+ attachment.desiredTableNames.some((tableName) => changedTables.has(tableName));
1910
+ const nextPart = {};
1911
+ if (wantsChangedRows)
1912
+ nextPart.rowsPatch = rowsPatch;
1913
+ if (isSourceClientGroup && hasLastMutationIDChanges)
1914
+ nextPart.lastMutationIDChanges = part.lastMutationIDChanges;
1915
+ if (!nextPart.rowsPatch && !nextPart.lastMutationIDChanges)
1916
+ continue;
1917
+ this.sendSyncPoke(ws, attachment, nextPart);
1918
+ }
1919
+ }
1920
+ syncRowPatchFromChange(change) {
1921
+ if (change.op === 'DELETE')
1922
+ return {
1923
+ op: 'del',
1924
+ tableName: change.tableName,
1925
+ id: this.primaryKeyValue(change.tableName, change.oldData || {}),
1926
+ };
1927
+ return {
1928
+ op: 'put',
1929
+ tableName: change.tableName,
1930
+ value: this.normalizeRow(change.tableName, change.rowData || {}),
1931
+ };
1932
+ }
1933
+ primaryKeyValue(tableName, row) {
1934
+ const pk = this.primaryKeyForTable(tableName, []);
1935
+ if (pk.length)
1936
+ return Object.fromEntries(pk.map((column) => [column, row[column]]));
1937
+ if ('id' in row)
1938
+ return { id: row.id };
1939
+ return row;
1940
+ }
1941
+ cookie() {
1942
+ return String(this.watermark()).padStart(20, '0');
1943
+ }
1944
+ nextCookie() {
1945
+ const watermark = this.watermarks.next();
1946
+ this.watermarks.mark(watermark);
1947
+ return String(watermark).padStart(20, '0');
1948
+ }
1949
+ readSocketAttachment(socket) {
1950
+ const attachment = socket.deserializeAttachment();
1951
+ if (!attachment)
1952
+ return null;
1953
+ return {
1954
+ ...attachment,
1955
+ initialized: attachment.initialized === true,
1956
+ desiredTableNames: attachment.desiredTableNames || [],
1957
+ desiredQueries: attachment.desiredQueries || [],
1958
+ };
1959
+ }
1960
+ sendJSON(socket, msg) {
1961
+ try {
1962
+ socket.send(JSON.stringify(msg));
1963
+ }
1964
+ catch { }
1965
+ }
1966
+ parseMessage(data) {
1967
+ try {
1968
+ return JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));
1969
+ }
1970
+ catch {
1971
+ return null;
1972
+ }
1973
+ }
1974
+ }
1975
+ export default {
1976
+ async fetch(request, env) {
1977
+ // every endpoint is served by the one singleton ZeroDO (its fetch() does the
1978
+ // routing, CORS preflight, and 404s). forward unconditionally rather than
1979
+ // re-listing each path here — a second route table only drifts from the DO's.
1980
+ const id = env.ZERO_DO.idFromName('singleton');
1981
+ return env.ZERO_DO.get(id).fetch(request);
1982
+ },
1983
+ };
1984
+ function decodeInitConnection(secProtocol) {
1985
+ try {
1986
+ const decoded = decodeURIComponent(secProtocol);
1987
+ const bytes = Uint8Array.from(atob(decoded), (char) => char.charCodeAt(0));
1988
+ const protocols = JSON.parse(new TextDecoder().decode(bytes));
1989
+ const message = protocols.initConnectionMessage;
1990
+ if (Array.isArray(message) && message[0] === 'initConnection') {
1991
+ return message;
1992
+ }
1993
+ return null;
1994
+ }
1995
+ catch {
1996
+ return null;
1997
+ }
1998
+ }
1999
+ //# sourceMappingURL=worker.js.map