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,723 @@
1
+ import { stripPublicPrefix } from '../do-sql-tracking.js';
2
+ /** Bump whenever the generated trigger bodies or buffer shape change. */
3
+ export const CDC_SCHEMA_VERSION = 2;
4
+ const CDC_TABLES = '_orez_cdc_tables';
5
+ const CDC_BUFFER = '_orez_cdc_buffer';
6
+ const CDC_TRIGGER_PREFIX = '_orez_cdc_';
7
+ const JSON_OBJECT_COLUMNS_PER_CHUNK = 50;
8
+ const CDC_BUFFER_COLUMNS = [
9
+ 'seq',
10
+ 'table_name',
11
+ 'op',
12
+ 'row_json',
13
+ 'old_json',
14
+ 'new_rowid',
15
+ 'old_rowid',
16
+ ];
17
+ function quoteIdent(name) {
18
+ return `"${name.replace(/"/g, '""')}"`;
19
+ }
20
+ function quoteLiteral(value) {
21
+ return `'${value.replace(/'/g, "''")}'`;
22
+ }
23
+ function triggerStem(table) {
24
+ const bytes = new TextEncoder().encode(table);
25
+ let encoded = '';
26
+ for (const byte of bytes)
27
+ encoded += byte.toString(16).padStart(2, '0');
28
+ return `${CDC_TRIGGER_PREFIX}${encoded}`;
29
+ }
30
+ function triggerNames(table) {
31
+ const stem = triggerStem(table);
32
+ return [`${stem}_insert`, `${stem}_update`, `${stem}_delete`];
33
+ }
34
+ // ── storage-journal value codec ────────────────────────────────────────────
35
+ //
36
+ // Every column is captured as a tagged string so the undo path can rebuild the
37
+ // exact SQLite value, storage class included:
38
+ //
39
+ // n NULL
40
+ // i<decimal> INTEGER, full signed 64-bit range as text
41
+ // r<%!.17g> REAL, 17 significant digits round-trips a double exactly.
42
+ // 'Inf'/'-Inf' for the infinities.
43
+ // s<text> TEXT
44
+ // b<hex> BLOB, lowercase hex
45
+ //
46
+ // A JSON number cannot carry an int64 (JSON.parse silently rounds anything past
47
+ // 2^53), CAST(real AS TEXT) is lossy (SQLite's default 15 digits turns
48
+ // MAX_DOUBLE into Inf), and SQLite's JSON functions reject BLOB values outright.
49
+ // Text tags dodge all three, plus every driver's differing int64/BigInt support.
50
+ /**
51
+ * Build the SQL expression that encodes one column for the journal.
52
+ *
53
+ * The `!` in `%!.17g` is SQLite's alternate-form-2 flag and it is load-bearing:
54
+ * it forces all 17 requested digits. Plain `%.17g` still applies SQLite's
55
+ * shortest-representation logic and silently drops digits, which turns
56
+ * MAX_DOUBLE into a value that reads back as `Inf` and collapses
57
+ * 0.30000000000000004 to 0.3. Do not "simplify" it away.
58
+ */
59
+ function journalValueSql(alias, column) {
60
+ const value = `${alias}.${quoteIdent(column)}`;
61
+ return (`CASE typeof(${value})` +
62
+ ` WHEN 'integer' THEN 'i' || CAST(${value} AS TEXT)` +
63
+ ` WHEN 'real' THEN 'r' || printf('%!.17g', ${value})` +
64
+ ` WHEN 'text' THEN 's' || ${value}` +
65
+ ` WHEN 'blob' THEN 'b' || lower(hex(${value}))` +
66
+ ` ELSE 'n' END`);
67
+ }
68
+ // SQLite's CAST is forgiving in exactly the wrong way for a journal: it reads
69
+ // '12junk' as 12 and 'junk' as 0.0, so a corrupt payload would restore a
70
+ // plausible but wrong value instead of failing. Every numeric payload is
71
+ // therefore checked against the canonical form this codec emits.
72
+ const INTEGER_PAYLOAD = /^-?(?:0|[1-9]\d*)$/;
73
+ const REAL_PAYLOAD = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
74
+ const INT64_MIN = -(2n ** 63n);
75
+ const INT64_MAX = 2n ** 63n - 1n;
76
+ function integerPayload(body) {
77
+ if (!INTEGER_PAYLOAD.test(body)) {
78
+ throw new Error(`cdc journal: corrupt integer payload ${JSON.stringify(body)}`);
79
+ }
80
+ // An out-of-range literal saturates rather than errors, which would restore a
81
+ // clamped value under the same silent-corruption failure mode.
82
+ const value = BigInt(body);
83
+ if (value < INT64_MIN || value > INT64_MAX) {
84
+ throw new Error(`cdc journal: integer payload out of int64 range ${body}`);
85
+ }
86
+ return body;
87
+ }
88
+ function realPayload(body) {
89
+ if (body === 'Inf' || body === '-Inf')
90
+ return body;
91
+ if (REAL_PAYLOAD.test(body)) {
92
+ const value = Number(body);
93
+ const mantissa = body.split(/[eE]/, 1)[0] ?? body;
94
+ if (Number.isFinite(value) && (value !== 0 || !/[1-9]/.test(mantissa))) {
95
+ return body;
96
+ }
97
+ }
98
+ throw new Error(`cdc journal: corrupt real payload ${JSON.stringify(body)}`);
99
+ }
100
+ function realFromText(text) {
101
+ if (text === 'Inf')
102
+ return Number.POSITIVE_INFINITY;
103
+ if (text === '-Inf')
104
+ return Number.NEGATIVE_INFINITY;
105
+ return Number(text);
106
+ }
107
+ /**
108
+ * Convert a journal image to the Zero wire image: plain JSON, with blobs in
109
+ * postgres's bytea text format, which is what the pgoutput consumer downstream
110
+ * of `_zero_changes` expects.
111
+ */
112
+ function journalToWire(record) {
113
+ if (!record)
114
+ return null;
115
+ const wire = {};
116
+ for (const [column, encoded] of Object.entries(record)) {
117
+ const body = encoded.slice(1);
118
+ switch (encoded[0]) {
119
+ case 'i': {
120
+ // A JSON number cannot hold an int64. Past 2^53 the nearest double is a
121
+ // different integer, so a snowflake id would reach the changefeed as
122
+ // the wrong value. Those keep their exact decimal text; everything in
123
+ // range stays a plain number.
124
+ const exact = integerPayload(body);
125
+ const numeric = Number(exact);
126
+ wire[column] = Number.isSafeInteger(numeric) ? numeric : exact;
127
+ break;
128
+ }
129
+ case 'r': {
130
+ const real = realPayload(body);
131
+ // JSON.stringify turns JS infinities into null. PostgreSQL accepts
132
+ // these spellings in float text format, and strings remain exact in
133
+ // the JSON-backed change log.
134
+ wire[column] =
135
+ real === 'Inf' ? 'Infinity' : real === '-Inf' ? '-Infinity' : realFromText(real);
136
+ break;
137
+ }
138
+ case 's':
139
+ wire[column] = body;
140
+ break;
141
+ case 'b':
142
+ if (!/^(?:[0-9a-f]{2})*$/.test(body)) {
143
+ throw new Error(`cdc journal: corrupt blob payload ${JSON.stringify(body)}`);
144
+ }
145
+ wire[column] = `\\x${body}`;
146
+ break;
147
+ case 'n':
148
+ if (body !== '') {
149
+ throw new Error(`cdc journal: corrupt null payload ${JSON.stringify(body)}`);
150
+ }
151
+ wire[column] = null;
152
+ break;
153
+ default:
154
+ throw new Error(`cdc journal: unknown value tag for column ${column}`);
155
+ }
156
+ }
157
+ return wire;
158
+ }
159
+ /**
160
+ * Rebuild the exact SQLite value for an undo statement. Integers and reals go
161
+ * back through CAST so the full int64 range and every double survive without
162
+ * depending on the driver's numeric binding.
163
+ */
164
+ export function journalValueSqlBinding(encoded) {
165
+ const body = encoded.slice(1);
166
+ switch (encoded[0]) {
167
+ case 'i':
168
+ return { expr: 'CAST(? AS INTEGER)', params: [integerPayload(body)] };
169
+ case 'r': {
170
+ const real = realPayload(body);
171
+ if (real === 'Inf')
172
+ return { expr: '9e999', params: [] };
173
+ if (real === '-Inf')
174
+ return { expr: '-9e999', params: [] };
175
+ return { expr: 'CAST(? AS REAL)', params: [real] };
176
+ }
177
+ case 's':
178
+ return { expr: '?', params: [body] };
179
+ case 'b':
180
+ if (!/^(?:[0-9a-f]{2})*$/.test(body)) {
181
+ throw new Error(`cdc journal: corrupt blob payload ${JSON.stringify(body)}`);
182
+ }
183
+ return { expr: `x'${body}'`, params: [] };
184
+ case 'n':
185
+ if (body !== '') {
186
+ throw new Error(`cdc journal: corrupt null payload ${JSON.stringify(body)}`);
187
+ }
188
+ return { expr: 'NULL', params: [] };
189
+ default:
190
+ // Never fail open to NULL. An unrecognized tag means the journal is
191
+ // corrupt, and quietly restoring the column as NULL would destroy the
192
+ // value the rollback exists to bring back.
193
+ throw new Error(`cdc journal: unknown value tag ${JSON.stringify(encoded)}`);
194
+ }
195
+ }
196
+ export function parseJournalRecord(value) {
197
+ if (value === null || value === undefined || value === '')
198
+ return null;
199
+ const parsed = typeof value === 'object' ? value : JSON.parse(String(value));
200
+ if (parsed === null ||
201
+ Array.isArray(parsed) ||
202
+ typeof parsed !== 'object' ||
203
+ Object.values(parsed).some((encoded) => typeof encoded !== 'string')) {
204
+ throw new Error('cdc journal: corrupt row image');
205
+ }
206
+ return parsed;
207
+ }
208
+ // ── schema introspection ──────────────────────────────────────────────────
209
+ /**
210
+ * Read stable-identity metadata from the live schema. Returns null when the
211
+ * table cannot be undone row-by-row (no rowid and no primary key), which keeps
212
+ * capture off it entirely so the caller falls back to table snapshots.
213
+ */
214
+ export function tableIdentity(sql, table) {
215
+ let info;
216
+ try {
217
+ info = sql.exec(`PRAGMA table_xinfo(${quoteIdent(table)})`).toArray();
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ const columns = info.map((row) => String(row.name ?? '')).filter(Boolean);
223
+ if (columns.length === 0)
224
+ return null;
225
+ const writableColumns = writableColumnsOf(info);
226
+ const keyColumns = info
227
+ .filter((row) => Number(row.pk ?? 0) > 0)
228
+ .sort((a, b) => Number(a.pk ?? 0) - Number(b.pk ?? 0))
229
+ .map((row) => String(row.name ?? ''))
230
+ .filter(Boolean);
231
+ const rowidAlias = detectRowidAlias(sql, table, columns);
232
+ // A primary key that IS the rowid needs no restoring of its own: writing the
233
+ // column back puts the row at its original rowid.
234
+ //
235
+ // Ask the schema, not the declared type. "INTEGER PRIMARY KEY" is a rowid
236
+ // alias but "INTEGER PRIMARY KEY DESC" is not, and neither is "INT PRIMARY
237
+ // KEY", so matching on the type string gets both wrong. SQLite builds a real
238
+ // index for every primary key EXCEPT a rowid alias, so the absence of an
239
+ // origin='pk' index is the exact test.
240
+ const rowidColumn = rowidAlias && keyColumns.length === 1 && !hasPrimaryKeyIndex(sql, table)
241
+ ? keyColumns[0]
242
+ : null;
243
+ if (!rowidAlias && keyColumns.length === 0)
244
+ return null;
245
+ return { rowidAlias, rowidColumn, keyColumns, writableColumns, columns };
246
+ }
247
+ function hasPrimaryKeyIndex(sql, table) {
248
+ try {
249
+ return sql
250
+ .exec(`PRAGMA index_list(${quoteIdent(table)})`)
251
+ .toArray()
252
+ .some((row) => String(row.origin ?? '') === 'pk');
253
+ }
254
+ catch {
255
+ return true;
256
+ }
257
+ }
258
+ /**
259
+ * table_xinfo.hidden: 0 ordinary, 1 virtual-table hidden, 2 VIRTUAL generated,
260
+ * 3 STORED generated. SQLite refuses to INSERT or UPDATE a generated column, so
261
+ * no restore may ever name one.
262
+ */
263
+ function writableColumnsOf(info) {
264
+ return info
265
+ .filter((row) => Number(row.hidden ?? 0) === 0)
266
+ .map((row) => String(row.name ?? ''))
267
+ .filter(Boolean);
268
+ }
269
+ /**
270
+ * Drop every trigger on these tables and return their definitions.
271
+ *
272
+ * A restore must not fire the table's own business triggers. Undoing an INSERT
273
+ * with a DELETE would run its AFTER DELETE trigger and write side effects the
274
+ * original transaction never made, and if that side effect lands on a captured
275
+ * table, its CDC trigger stages a phantom change on top. Dropping only the
276
+ * generated CDC triggers leaves both holes open, so every trigger goes.
277
+ */
278
+ export function suspendTriggers(sql, tables) {
279
+ const unique = [...new Set(tables)].filter(Boolean);
280
+ if (unique.length === 0)
281
+ return [];
282
+ const placeholders = unique.map(() => '?').join(', ');
283
+ const triggers = sql
284
+ .exec(`SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name IN (${placeholders}) ORDER BY name`, ...unique)
285
+ .toArray()
286
+ .map((row) => ({ name: String(row.name ?? ''), sql: String(row.sql ?? '') }))
287
+ .filter((trigger) => trigger.name && trigger.sql);
288
+ for (const trigger of triggers) {
289
+ sql.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(trigger.name)}`);
290
+ }
291
+ return triggers;
292
+ }
293
+ /** Recreate triggers suspended for a restore, verbatim. */
294
+ export function restoreTriggers(sql, triggers) {
295
+ for (const trigger of triggers)
296
+ sql.exec(trigger.sql);
297
+ }
298
+ /** The columns a restore is allowed to write. Empty when the table is gone. */
299
+ export function writableColumns(sql, table) {
300
+ try {
301
+ return writableColumnsOf(sql.exec(`PRAGMA table_xinfo(${quoteIdent(table)})`).toArray());
302
+ }
303
+ catch {
304
+ return [];
305
+ }
306
+ }
307
+ function detectRowidAlias(sql, table, columns) {
308
+ const shadowed = new Set(columns.map((column) => column.toLowerCase()));
309
+ for (const alias of ['_rowid_', 'rowid', 'oid']) {
310
+ if (shadowed.has(alias))
311
+ continue;
312
+ try {
313
+ sql.exec(`SELECT ${alias} FROM ${quoteIdent(table)} LIMIT 0`).toArray();
314
+ return alias;
315
+ }
316
+ catch {
317
+ // WITHOUT ROWID: no alias resolves, so stop probing.
318
+ return null;
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+ // ── trigger generation ────────────────────────────────────────────────────
324
+ function jsonPath(column) {
325
+ return quoteLiteral(`$.${JSON.stringify(column)}`);
326
+ }
327
+ function jsonObject(alias, columns) {
328
+ if (columns.length === 0)
329
+ return `json_object()`;
330
+ const first = columns.slice(0, JSON_OBJECT_COLUMNS_PER_CHUNK);
331
+ let expression = `json_object(${first
332
+ .flatMap((column) => [quoteLiteral(column), journalValueSql(alias, column)])
333
+ .join(', ')})`;
334
+ for (let offset = JSON_OBJECT_COLUMNS_PER_CHUNK; offset < columns.length; offset += JSON_OBJECT_COLUMNS_PER_CHUNK) {
335
+ const args = columns
336
+ .slice(offset, offset + JSON_OBJECT_COLUMNS_PER_CHUNK)
337
+ .flatMap((column) => [jsonPath(column), journalValueSql(alias, column)]);
338
+ // json_patch implements RFC 7396 and would DELETE keys whose values are
339
+ // SQL/JSON null. json_set preserves those keys while keeping each call
340
+ // below older SQLite builds' function-argument limit.
341
+ expression = `json_set(${expression}, ${args.join(', ')})`;
342
+ }
343
+ return expression;
344
+ }
345
+ function rowidSql(alias, identity) {
346
+ if (!identity.rowidAlias)
347
+ return 'NULL';
348
+ return `CAST(${alias}.${identity.rowidAlias} AS TEXT)`;
349
+ }
350
+ function changedWhen(columns) {
351
+ if (columns.length === 0)
352
+ return '0';
353
+ return columns
354
+ .map((column) => `OLD.${quoteIdent(column)} IS NOT NEW.${quoteIdent(column)}`)
355
+ .join(' OR ');
356
+ }
357
+ function unquoteSqlIdentifier(identifier) {
358
+ if (identifier.startsWith('"') && identifier.endsWith('"')) {
359
+ return identifier.slice(1, -1).replace(/""/g, '"');
360
+ }
361
+ if (identifier.startsWith('`') && identifier.endsWith('`')) {
362
+ return identifier.slice(1, -1).replace(/``/g, '`');
363
+ }
364
+ if (identifier.startsWith('[') && identifier.endsWith(']')) {
365
+ return identifier.slice(1, -1);
366
+ }
367
+ return identifier;
368
+ }
369
+ const SCHEMA_CHANGE_GATE_RE = /\b(?:ALTER|CREATE|DROP)\b/i;
370
+ const SQL_IDENTIFIER = '("(?:[^"]|"")*"|`(?:[^`]|``)*`|\\[[^\\]]+\\]|[^\\s;(]+)';
371
+ const SCHEMA_CHANGE_PATTERNS = [
372
+ new RegExp(`\\bALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${SQL_IDENTIFIER}`, 'gi'),
373
+ new RegExp(`\\b(?:CREATE|DROP)\\s+TABLE\\s+(?:IF\\s+(?:NOT\\s+)?EXISTS\\s+)?${SQL_IDENTIFIER}`, 'gi'),
374
+ ];
375
+ /**
376
+ * Return physical tables whose SQLite schema may be changed by this statement.
377
+ * The DO compiler emits one SQLite DDL statement at a time and quotes physical
378
+ * names, but accepting every SQLite identifier form also covers direct /exec
379
+ * callers and keeps CDC independent of the compiler.
380
+ */
381
+ export function schemaChangeTargets(sql) {
382
+ if (!SCHEMA_CHANGE_GATE_RE.test(sql))
383
+ return [];
384
+ const targets = new Set();
385
+ for (const pattern of SCHEMA_CHANGE_PATTERNS) {
386
+ for (const match of sql.matchAll(pattern)) {
387
+ const name = match[1];
388
+ if (name)
389
+ targets.add(unquoteSqlIdentifier(name));
390
+ }
391
+ }
392
+ return [...targets];
393
+ }
394
+ /**
395
+ * Transactional logical-row capture for the authoritative SQLite database.
396
+ *
397
+ * Generated AFTER triggers write full before/after row images to a staging
398
+ * table in the same SQLite statement as the application write. The owner
399
+ * drains that staging table before its storage transaction returns and moves
400
+ * the rows to either `_zero_changes` or `_zero_pending_changes`. Consequently
401
+ * a failed statement or storage transaction cannot leave an orphaned change.
402
+ *
403
+ * Every mutator of the in-memory registration cache runs inside the owner's
404
+ * storage transaction, so the cache can outlive an abort that rolled its SQLite
405
+ * side back. `reload()` re-derives the whole cache from SQLite and MUST be
406
+ * called on any aborted transaction; see `atomically` in worker.ts.
407
+ */
408
+ export class TransactionalCdc {
409
+ sql;
410
+ #active = false;
411
+ #registrations = new Map();
412
+ #verified = new Set();
413
+ constructor(sql) {
414
+ this.sql = sql;
415
+ this.#registrations = this.loadRegistrations();
416
+ this.#active = this.#registrations.size > 0;
417
+ }
418
+ get active() {
419
+ return this.#active;
420
+ }
421
+ /** Force the next per-table ensure to re-read SQLite's live schema. */
422
+ invalidateSchema() {
423
+ this.#verified.clear();
424
+ }
425
+ /**
426
+ * Re-derive every cached decision from SQLite. Called after an aborted
427
+ * storage transaction, where SQLite rolled back the trigger and metadata
428
+ * writes but this object still remembers making them. Without it a table can
429
+ * stay "registered and verified" in memory with no trigger on disk, and every
430
+ * later write to it is silently uncaptured.
431
+ */
432
+ reload() {
433
+ // Build and validate the replacement before publishing it. A corrupt row
434
+ // must fail the request without erasing a still-live capture decision.
435
+ // Verification is different: an aborted transaction may have rolled its
436
+ // triggers back, so no cached verification can survive even a failed load.
437
+ this.#verified.clear();
438
+ const registrations = this.loadRegistrations();
439
+ this.#registrations = registrations;
440
+ this.#active = this.#registrations.size > 0;
441
+ }
442
+ loadRegistrations() {
443
+ const table = this.sql
444
+ .exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", CDC_TABLES)
445
+ .toArray();
446
+ if (table.length === 0)
447
+ return new Map();
448
+ this.ensureTables();
449
+ const rows = this.sql
450
+ .exec(`SELECT physical_table, table_name, columns_json, publish, schema_version FROM ${quoteIdent(CDC_TABLES)}`)
451
+ .toArray();
452
+ const registrations = new Map();
453
+ for (const row of rows) {
454
+ const physicalTable = String(row.physical_table ?? '');
455
+ const tableName = String(row.table_name ?? '');
456
+ if (!physicalTable || !tableName) {
457
+ throw new Error('cdc registrations: corrupt table identity');
458
+ }
459
+ let columns;
460
+ try {
461
+ columns = JSON.parse(String(row.columns_json));
462
+ }
463
+ catch (error) {
464
+ throw new Error(`cdc registrations: corrupt columns_json for ${JSON.stringify(physicalTable)}`, { cause: error });
465
+ }
466
+ if (!Array.isArray(columns) ||
467
+ columns.length === 0 ||
468
+ columns.some((column) => typeof column !== 'string') ||
469
+ new Set(columns).size !== columns.length) {
470
+ throw new Error(`cdc registrations: invalid columns_json for ${JSON.stringify(physicalTable)}`);
471
+ }
472
+ const publish = Number(row.publish);
473
+ const version = Number(row.schema_version);
474
+ if ((publish !== 0 && publish !== 1) ||
475
+ !Number.isSafeInteger(version) ||
476
+ version < 0) {
477
+ throw new Error(`cdc registrations: invalid metadata for ${JSON.stringify(physicalTable)}`);
478
+ }
479
+ registrations.set(physicalTable, {
480
+ tableName,
481
+ columns,
482
+ publish: publish === 1,
483
+ version,
484
+ });
485
+ }
486
+ return registrations;
487
+ }
488
+ ensureTables() {
489
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CDC_TABLES)} (` +
490
+ 'physical_table TEXT PRIMARY KEY, ' +
491
+ 'table_name TEXT NOT NULL, ' +
492
+ 'columns_json TEXT NOT NULL, ' +
493
+ 'publish INTEGER NOT NULL DEFAULT 1, ' +
494
+ `schema_version INTEGER NOT NULL DEFAULT ${CDC_SCHEMA_VERSION})`);
495
+ const tableColumns = this.sql
496
+ .exec(`PRAGMA table_info(${quoteIdent(CDC_TABLES)})`)
497
+ .toArray();
498
+ if (!tableColumns.some((column) => String(column.name) === 'publish')) {
499
+ this.sql.exec(`ALTER TABLE ${quoteIdent(CDC_TABLES)} ADD COLUMN publish INTEGER NOT NULL DEFAULT 1`);
500
+ }
501
+ if (!tableColumns.some((column) => String(column.name) === 'schema_version')) {
502
+ this.sql.exec(`ALTER TABLE ${quoteIdent(CDC_TABLES)} ADD COLUMN schema_version INTEGER NOT NULL DEFAULT 0`);
503
+ }
504
+ // The buffer only ever holds rows for the statement being drained, so a
505
+ // shape change from an older schema version can simply replace it.
506
+ const bufferColumns = this.sql
507
+ .exec(`PRAGMA table_info(${quoteIdent(CDC_BUFFER)})`)
508
+ .toArray()
509
+ .map((column) => String(column.name ?? ''));
510
+ if (bufferColumns.length > 0 &&
511
+ !CDC_BUFFER_COLUMNS.every((column) => bufferColumns.includes(column))) {
512
+ this.sql.exec(`DROP TABLE IF EXISTS ${quoteIdent(CDC_BUFFER)}`);
513
+ }
514
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(CDC_BUFFER)} (` +
515
+ 'seq INTEGER PRIMARY KEY, ' +
516
+ 'table_name TEXT NOT NULL, ' +
517
+ "op TEXT NOT NULL CHECK (op IN ('INSERT', 'UPDATE', 'DELETE')), " +
518
+ 'row_json TEXT, ' +
519
+ 'old_json TEXT, ' +
520
+ 'new_rowid TEXT, ' +
521
+ 'old_rowid TEXT)');
522
+ }
523
+ tableExists(table) {
524
+ // Durable Object SQL's `one()` throws when a query returns zero rows,
525
+ // unlike several SQLite adapters that return undefined. Existence checks
526
+ // need the portable zero-or-one-row cursor contract.
527
+ return (this.sql
528
+ .exec("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", table)
529
+ .toArray().length > 0);
530
+ }
531
+ triggersExist(table) {
532
+ const names = triggerNames(table);
533
+ const placeholders = names.map(() => '?').join(', ');
534
+ return (this.sql
535
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (${placeholders})`, ...names)
536
+ .toArray().length === names.length);
537
+ }
538
+ registered(table) {
539
+ return this.#registrations.get(table) ?? null;
540
+ }
541
+ capturesTable(tableName) {
542
+ return (this.#active &&
543
+ [...this.#registrations.values()].some((registration) => registration.publish && registration.tableName === tableName));
544
+ }
545
+ /** True when a DDL statement must atomically suspend and rebuild CDC triggers. */
546
+ capturesSchemaChange(sql) {
547
+ return schemaChangeTargets(sql).some((table) => this.#registrations.has(table));
548
+ }
549
+ dropTriggers(table) {
550
+ for (const name of triggerNames(table)) {
551
+ this.sql.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(name)}`);
552
+ }
553
+ }
554
+ /**
555
+ * Drop generated triggers before SQLite rewrites a captured table. SQLite
556
+ * refuses DROP/RENAME COLUMN while any trigger still references the old row
557
+ * shape. The caller must run this, the DDL, and finishSchemaChange in one
558
+ * storage transaction so capture is never observably disabled.
559
+ */
560
+ beginSchemaChange(sql) {
561
+ const suspended = [];
562
+ for (const physicalTableName of schemaChangeTargets(sql)) {
563
+ const registration = this.registered(physicalTableName);
564
+ if (!registration)
565
+ continue;
566
+ this.dropTriggers(physicalTableName);
567
+ this.#verified.delete(physicalTableName);
568
+ suspended.push({
569
+ physicalTableName,
570
+ tableName: registration.tableName,
571
+ ...(registration.publish ? null : { publish: false }),
572
+ });
573
+ }
574
+ return suspended;
575
+ }
576
+ /** Re-introspect changed tables and rebuild their triggers from the live shape. */
577
+ finishSchemaChange(suspended) {
578
+ for (const registration of suspended) {
579
+ if (this.ensureTable(registration, true))
580
+ continue;
581
+ this.sql.exec(`DELETE FROM ${quoteIdent(CDC_TABLES)} WHERE physical_table = ?`, registration.physicalTableName);
582
+ this.#registrations.delete(registration.physicalTableName);
583
+ this.#verified.delete(registration.physicalTableName);
584
+ }
585
+ this.#active = this.#registrations.size > 0;
586
+ }
587
+ installTriggers(registration, identity) {
588
+ const physicalTable = quoteIdent(registration.physicalTableName);
589
+ const logicalTable = quoteLiteral(registration.tableName);
590
+ const [insertName, updateName, deleteName] = triggerNames(registration.physicalTableName).map(quoteIdent);
591
+ const columns = identity.columns;
592
+ const newRow = jsonObject('NEW', columns);
593
+ const oldRow = jsonObject('OLD', columns);
594
+ const newRowid = rowidSql('NEW', identity);
595
+ const oldRowid = rowidSql('OLD', identity);
596
+ const insertInto = `INSERT INTO ${quoteIdent(CDC_BUFFER)} ` +
597
+ '(table_name, op, row_json, old_json, new_rowid, old_rowid)';
598
+ this.sql.exec(`CREATE TRIGGER ${insertName} AFTER INSERT ON ${physicalTable} BEGIN ` +
599
+ `${insertInto} VALUES (${logicalTable}, 'INSERT', ${newRow}, NULL, ${newRowid}, NULL); END`);
600
+ this.sql.exec(`CREATE TRIGGER ${updateName} AFTER UPDATE ON ${physicalTable} ` +
601
+ `WHEN ${changedWhen(columns)} BEGIN ` +
602
+ `${insertInto} VALUES (${logicalTable}, 'UPDATE', ${newRow}, ${oldRow}, ${newRowid}, ${oldRowid}); END`);
603
+ this.sql.exec(`CREATE TRIGGER ${deleteName} AFTER DELETE ON ${physicalTable} BEGIN ` +
604
+ `${insertInto} VALUES (${logicalTable}, 'DELETE', NULL, ${oldRow}, NULL, ${oldRowid}); END`);
605
+ }
606
+ /**
607
+ * Ensure one table is captured. Returns false when the table does not exist
608
+ * or cannot be undone row-by-row, which leaves the caller on its table
609
+ * snapshot path rather than capturing changes it could never roll back.
610
+ */
611
+ ensureTable(registration, refresh = false) {
612
+ const physicalTableName = String(registration.physicalTableName || '');
613
+ const tableName = String(registration.tableName || '');
614
+ const cached = this.registered(physicalTableName);
615
+ // A rollback-only request must never demote an already published table.
616
+ const publish = cached?.publish || registration.publish !== false;
617
+ if (!refresh &&
618
+ cached?.tableName === tableName &&
619
+ cached.publish === publish &&
620
+ cached.version === CDC_SCHEMA_VERSION &&
621
+ this.#verified.has(physicalTableName)) {
622
+ return true;
623
+ }
624
+ if (!physicalTableName || !tableName || !this.tableExists(physicalTableName)) {
625
+ return false;
626
+ }
627
+ this.ensureTables();
628
+ // The request's column list is a cache hint, not authority. A backend can
629
+ // race an out-of-band migration with stale metadata; narrowing the trigger
630
+ // to that stale list would silently omit the new column forever.
631
+ const identity = tableIdentity(this.sql, physicalTableName);
632
+ if (!identity)
633
+ return false;
634
+ const capturedColumns = identity.columns;
635
+ const previous = this.registered(physicalTableName);
636
+ const unchanged = previous?.tableName === tableName &&
637
+ previous.publish === publish &&
638
+ previous.version === CDC_SCHEMA_VERSION &&
639
+ JSON.stringify(previous.columns) === JSON.stringify(capturedColumns) &&
640
+ this.triggersExist(physicalTableName);
641
+ if (!unchanged) {
642
+ this.dropTriggers(physicalTableName);
643
+ this.installTriggers({ physicalTableName, tableName }, identity);
644
+ this.sql.exec(`INSERT OR REPLACE INTO ${quoteIdent(CDC_TABLES)} ` +
645
+ '(physical_table, table_name, columns_json, publish, schema_version) VALUES (?, ?, ?, ?, ?)', physicalTableName, tableName, JSON.stringify(capturedColumns), publish ? 1 : 0, CDC_SCHEMA_VERSION);
646
+ this.#registrations.set(physicalTableName, {
647
+ tableName,
648
+ columns: capturedColumns,
649
+ publish,
650
+ version: CDC_SCHEMA_VERSION,
651
+ });
652
+ }
653
+ this.#verified.add(physicalTableName);
654
+ this.#active = true;
655
+ return true;
656
+ }
657
+ /** Replace the captured-table set with the caller's authoritative list. */
658
+ syncTables(registrations) {
659
+ const desired = new Map();
660
+ for (const registration of registrations ?? []) {
661
+ const physicalTableName = String(registration?.physicalTableName || '');
662
+ const tableName = String(registration?.tableName || '');
663
+ if (!physicalTableName || !tableName)
664
+ continue;
665
+ desired.set(physicalTableName, {
666
+ physicalTableName,
667
+ tableName,
668
+ publish: true,
669
+ ...(registration.columns?.length
670
+ ? { columns: registration.columns.map(String) }
671
+ : null),
672
+ });
673
+ }
674
+ if (!this.#active && desired.size === 0 && this.#registrations.size === 0)
675
+ return;
676
+ this.ensureTables();
677
+ const installed = [...this.#registrations.keys()];
678
+ for (const table of installed) {
679
+ const registration = this.#registrations.get(table);
680
+ if (desired.has(table) || registration?.publish === false)
681
+ continue;
682
+ this.dropTriggers(table);
683
+ this.sql.exec(`DELETE FROM ${quoteIdent(CDC_TABLES)} WHERE physical_table = ?`, table);
684
+ this.#registrations.delete(table);
685
+ this.#verified.delete(table);
686
+ }
687
+ for (const registration of desired.values())
688
+ this.ensureTable(registration, true);
689
+ this.#active = this.#registrations.size > 0;
690
+ }
691
+ /** Drain all changes captured by the just-completed SQLite statement. */
692
+ drain() {
693
+ if (!this.#active)
694
+ return [];
695
+ const rows = this.sql
696
+ .exec(`SELECT seq, table_name, op, row_json, old_json, new_rowid, old_rowid ` +
697
+ `FROM ${quoteIdent(CDC_BUFFER)} ORDER BY seq`)
698
+ .toArray();
699
+ if (rows.length === 0)
700
+ return [];
701
+ this.sql.exec(`DELETE FROM ${quoteIdent(CDC_BUFFER)}`);
702
+ return rows.map((row) => {
703
+ const tableName = String(row.table_name);
704
+ const physicalTableName = [...this.#registrations].find(([, registration]) => registration.tableName === tableName)?.[0] ?? stripPublicPrefix(tableName);
705
+ const registration = this.#registrations.get(physicalTableName);
706
+ const rowJournal = parseJournalRecord(row.row_json);
707
+ const oldJournal = parseJournalRecord(row.old_json);
708
+ return {
709
+ physicalTableName,
710
+ tableName,
711
+ op: String(row.op),
712
+ rowData: journalToWire(rowJournal),
713
+ oldData: journalToWire(oldJournal),
714
+ rowJournal,
715
+ oldJournal,
716
+ newRowid: row.new_rowid === null ? null : String(row.new_rowid),
717
+ oldRowid: row.old_rowid === null ? null : String(row.old_rowid),
718
+ ...(registration?.publish === false ? { publish: false } : null),
719
+ };
720
+ });
721
+ }
722
+ }
723
+ //# sourceMappingURL=cdc.js.map