orez 0.4.73 → 0.5.2

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