orez-lite 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/browser/generated/sqlite3-browser.wasm +0 -0
- package/dist/browser/generated/sync_wasm.js +922 -0
- package/dist/browser/generated/sync_wasm_bg.wasm +0 -0
- package/dist/browser/host.d.ts +10 -0
- package/dist/browser/host.d.ts.map +1 -0
- package/dist/browser/host.js +496 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/idb-snapshot.d.ts +11 -0
- package/dist/browser/idb-snapshot.d.ts.map +1 -0
- package/dist/browser/idb-snapshot.js +122 -0
- package/dist/browser/idb-snapshot.js.map +1 -0
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-port.d.ts +7 -0
- package/dist/browser/message-port.d.ts.map +1 -0
- package/dist/browser/message-port.js +188 -0
- package/dist/browser/message-port.js.map +1 -0
- package/dist/browser/sqlite-adapter.d.ts +53 -0
- package/dist/browser/sqlite-adapter.d.ts.map +1 -0
- package/dist/browser/sqlite-adapter.js +126 -0
- package/dist/browser/sqlite-adapter.js.map +1 -0
- package/dist/browser/types.d.ts +52 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cf-deploy/bundle.d.ts +18 -0
- package/dist/cf-deploy/bundle.d.ts.map +1 -0
- package/dist/cf-deploy/bundle.js +273 -0
- package/dist/cf-deploy/bundle.js.map +1 -0
- package/dist/cf-deploy/config.d.ts +12 -0
- package/dist/cf-deploy/config.d.ts.map +1 -0
- package/dist/cf-deploy/config.js +11 -0
- package/dist/cf-deploy/config.js.map +1 -0
- package/dist/cf-deploy/index.d.ts +10 -0
- package/dist/cf-deploy/index.d.ts.map +1 -0
- package/dist/cf-deploy/index.js +12 -0
- package/dist/cf-deploy/index.js.map +1 -0
- package/dist/cf-deploy/leaves.d.ts +8 -0
- package/dist/cf-deploy/leaves.d.ts.map +1 -0
- package/dist/cf-deploy/leaves.js +66 -0
- package/dist/cf-deploy/leaves.js.map +1 -0
- package/dist/cf-deploy/migration.d.ts +27 -0
- package/dist/cf-deploy/migration.d.ts.map +1 -0
- package/dist/cf-deploy/migration.js +734 -0
- package/dist/cf-deploy/migration.js.map +1 -0
- package/dist/cf-deploy/nativeMigrations.d.ts +24 -0
- package/dist/cf-deploy/nativeMigrations.d.ts.map +1 -0
- package/dist/cf-deploy/nativeMigrations.js +185 -0
- package/dist/cf-deploy/nativeMigrations.js.map +1 -0
- package/dist/cf-deploy/prune.d.ts +14 -0
- package/dist/cf-deploy/prune.d.ts.map +1 -0
- package/dist/cf-deploy/prune.js +235 -0
- package/dist/cf-deploy/prune.js.map +1 -0
- package/dist/cf-deploy/readiness.d.ts +11 -0
- package/dist/cf-deploy/readiness.d.ts.map +1 -0
- package/dist/cf-deploy/readiness.js +56 -0
- package/dist/cf-deploy/readiness.js.map +1 -0
- package/dist/cf-deploy/sources.d.ts +3 -0
- package/dist/cf-deploy/sources.d.ts.map +1 -0
- package/dist/cf-deploy/sources.js +24 -0
- package/dist/cf-deploy/sources.js.map +1 -0
- package/dist/cf-deploy/wrangler.d.ts +9 -0
- package/dist/cf-deploy/wrangler.d.ts.map +1 -0
- package/dist/cf-deploy/wrangler.js +48 -0
- package/dist/cf-deploy/wrangler.js.map +1 -0
- package/dist/cf-do/application-sql.d.ts +49 -0
- package/dist/cf-do/application-sql.d.ts.map +1 -0
- package/dist/cf-do/application-sql.js +124 -0
- package/dist/cf-do/application-sql.js.map +1 -0
- package/dist/cf-do/cdc.d.ts +155 -0
- package/dist/cf-do/cdc.d.ts.map +1 -0
- package/dist/cf-do/cdc.js +723 -0
- package/dist/cf-do/cdc.js.map +1 -0
- package/dist/cf-do/cursor-pull.d.ts +27 -0
- package/dist/cf-do/cursor-pull.d.ts.map +1 -0
- package/dist/cf-do/cursor-pull.js +78 -0
- package/dist/cf-do/cursor-pull.js.map +1 -0
- package/dist/cf-do/lite-data-worker.d.ts +171 -0
- package/dist/cf-do/lite-data-worker.d.ts.map +1 -0
- package/dist/cf-do/lite-data-worker.js +685 -0
- package/dist/cf-do/lite-data-worker.js.map +1 -0
- package/dist/cf-do/namespace-backup.d.ts +85 -0
- package/dist/cf-do/namespace-backup.d.ts.map +1 -0
- package/dist/cf-do/namespace-backup.js +470 -0
- package/dist/cf-do/namespace-backup.js.map +1 -0
- package/dist/cf-do/row-undo.d.ts +36 -0
- package/dist/cf-do/row-undo.d.ts.map +1 -0
- package/dist/cf-do/row-undo.js +221 -0
- package/dist/cf-do/row-undo.js.map +1 -0
- package/dist/cf-do/tx-journal.d.ts +118 -0
- package/dist/cf-do/tx-journal.d.ts.map +1 -0
- package/dist/cf-do/tx-journal.js +717 -0
- package/dist/cf-do/tx-journal.js.map +1 -0
- package/dist/cf-do/watermark.d.ts +29 -0
- package/dist/cf-do/watermark.d.ts.map +1 -0
- package/dist/cf-do/watermark.js +105 -0
- package/dist/cf-do/watermark.js.map +1 -0
- package/dist/cf-do/worker.d.ts +282 -0
- package/dist/cf-do/worker.d.ts.map +1 -0
- package/dist/cf-do/worker.js +1999 -0
- package/dist/cf-do/worker.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/cloudflare-build.d.ts +9 -0
- package/dist/cloudflare-build.d.ts.map +1 -0
- package/dist/cloudflare-build.js +9 -0
- package/dist/cloudflare-build.js.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.js +2 -0
- package/dist/cloudflare-bun-wasm-loader.js.map +1 -0
- package/dist/cloudflare-query-compiler.d.ts +2 -0
- package/dist/cloudflare-query-compiler.d.ts.map +1 -0
- package/dist/cloudflare-query-compiler.js +2 -0
- package/dist/cloudflare-query-compiler.js.map +1 -0
- package/dist/cloudflare-runtime.d.ts +15 -0
- package/dist/cloudflare-runtime.d.ts.map +1 -0
- package/dist/cloudflare-runtime.js +11 -0
- package/dist/cloudflare-runtime.js.map +1 -0
- package/dist/cloudflare-sync.d.ts +3 -0
- package/dist/cloudflare-sync.d.ts.map +1 -0
- package/dist/cloudflare-sync.js +3 -0
- package/dist/cloudflare-sync.js.map +1 -0
- package/dist/cloudflare-transaction-query.d.ts +2 -0
- package/dist/cloudflare-transaction-query.d.ts.map +1 -0
- package/dist/cloudflare-transaction-query.js +2 -0
- package/dist/cloudflare-transaction-query.js.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.js +2 -0
- package/dist/cloudflare-vite-wasm-loader.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +3 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/do-sql-tracking.d.ts +114 -0
- package/dist/do-sql-tracking.d.ts.map +1 -0
- package/dist/do-sql-tracking.js +280 -0
- package/dist/do-sql-tracking.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/wake.d.ts +22 -0
- package/dist/wake.d.ts.map +1 -0
- package/dist/wake.js +114 -0
- package/dist/wake.js.map +1 -0
- package/dist/worker/cf-do-shim.d.ts +53 -0
- package/dist/worker/cf-do-shim.d.ts.map +1 -0
- package/dist/worker/cf-do-shim.js +66 -0
- package/dist/worker/cf-do-shim.js.map +1 -0
- package/dist/zero-http/encrypted-column-codec.d.ts +46 -0
- package/dist/zero-http/encrypted-column-codec.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-codec.js +676 -0
- package/dist/zero-http/encrypted-column-codec.js.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts +7 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.js +161 -0
- package/dist/zero-http/encrypted-column-conformance.js.map +1 -0
- package/dist/zero-http/fixture-schema.d.ts +127 -0
- package/dist/zero-http/fixture-schema.d.ts.map +1 -0
- package/dist/zero-http/fixture-schema.js +56 -0
- package/dist/zero-http/fixture-schema.js.map +1 -0
- package/dist/zero-http/mount.d.ts +71 -0
- package/dist/zero-http/mount.d.ts.map +1 -0
- package/dist/zero-http/mount.js +594 -0
- package/dist/zero-http/mount.js.map +1 -0
- package/dist/zero-http/payload-codec.d.ts +91 -0
- package/dist/zero-http/payload-codec.d.ts.map +1 -0
- package/dist/zero-http/payload-codec.js +10 -0
- package/dist/zero-http/payload-codec.js.map +1 -0
- package/dist/zero-http/server.d.ts +16 -0
- package/dist/zero-http/server.d.ts.map +1 -0
- package/dist/zero-http/server.js +270 -0
- package/dist/zero-http/server.js.map +1 -0
- package/dist/zero-http/test-harness.d.ts +32 -0
- package/dist/zero-http/test-harness.d.ts.map +1 -0
- package/dist/zero-http/test-harness.js +77 -0
- package/dist/zero-http/test-harness.js.map +1 -0
- package/dist/zero-http/transport.d.ts +71 -0
- package/dist/zero-http/transport.d.ts.map +1 -0
- package/dist/zero-http/transport.js +1106 -0
- package/dist/zero-http/transport.js.map +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,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
|