@delali/sirannon-db 0.1.3 → 0.1.5
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/README.md +655 -80
- package/dist/backup-scheduler/index.d.ts +3 -0
- package/dist/backup-scheduler/index.mjs +2 -0
- package/dist/change-tracker-CFTQ9TSn.d.ts +89 -0
- package/dist/chunk-3MCMONVP.mjs +115 -0
- package/dist/chunk-74UN4DIE.mjs +14 -0
- package/dist/chunk-ER7ODTDA.mjs +23 -0
- package/dist/chunk-FB2U2Q3Y.mjs +21 -0
- package/dist/chunk-GS7T5YMI.mjs +51 -0
- package/dist/chunk-O7BHI3CF.mjs +90 -0
- package/dist/chunk-PXKAKK2V.mjs +124 -0
- package/dist/chunk-UTO3ZAFS.mjs +514 -0
- package/dist/chunk-UVMVN3OT.mjs +111 -0
- package/dist/client/index.d.ts +137 -44
- package/dist/client/index.mjs +726 -26
- package/dist/core/index.d.ts +32 -241
- package/dist/core/index.mjs +294 -568
- package/dist/database-BVY1GqE7.d.ts +95 -0
- package/dist/driver/better-sqlite3.d.ts +8 -0
- package/dist/driver/better-sqlite3.mjs +63 -0
- package/dist/driver/bun.mjs +61 -0
- package/dist/driver/expo.mjs +55 -0
- package/dist/driver/node.d.ts +8 -0
- package/dist/driver/node.mjs +60 -0
- package/dist/driver/wa-sqlite.d.ts +34 -0
- package/dist/driver/wa-sqlite.mjs +141 -0
- package/dist/errors-C00ed08Q.d.ts +101 -0
- package/dist/file-migrations/index.d.ts +16 -0
- package/dist/file-migrations/index.mjs +128 -0
- package/dist/index-CLdNrcPz.d.ts +16 -0
- package/dist/replication/coordinator/etcd.d.ts +44 -0
- package/dist/replication/coordinator/etcd.mjs +650 -0
- package/dist/replication/index.d.ts +491 -0
- package/dist/replication/index.mjs +3784 -0
- package/dist/server/index.d.ts +121 -54
- package/dist/server/index.mjs +347 -114
- package/dist/sirannon-Cd-lK6T0.d.ts +31 -0
- package/dist/transport/grpc.d.ts +316 -0
- package/dist/transport/grpc.mjs +3341 -0
- package/dist/transport/memory.d.ts +221 -0
- package/dist/transport/memory.mjs +337 -0
- package/dist/types-B2byqt0B.d.ts +273 -0
- package/dist/types-BEu1I_9_.d.ts +139 -0
- package/dist/types-BFSsG77t.d.ts +29 -0
- package/dist/types-BeozgNPr.d.ts +26 -0
- package/dist/{types-DArCObcu.d.ts → types-D-74JiXb.d.ts} +80 -1
- package/dist/vfs-INWQ5DTE.mjs +2 -0
- package/package.json +106 -11
- package/dist/chunk-VI4UP4RR.mjs +0 -417
- package/dist/protocol-BX1H-_Mz.d.ts +0 -104
- package/dist/sirannon-BJ8Yd1Uf.d.ts +0 -148
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { decodeTaggedValues, SAFE_INT_BOUND_TEXT } from './chunk-GS7T5YMI.mjs';
|
|
2
|
+
import { CDCError } from './chunk-O7BHI3CF.mjs';
|
|
3
|
+
|
|
4
|
+
// src/core/cdc/trigger-sql.ts
|
|
5
|
+
async function dropCdcTriggers(conn, table) {
|
|
6
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_insert"`);
|
|
7
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_update"`);
|
|
8
|
+
await conn.exec(`DROP TRIGGER IF EXISTS "_sirannon_trg_${table}_delete"`);
|
|
9
|
+
}
|
|
10
|
+
async function installCdcTriggers(conn, changesTable, table, columns, pkColumns, replication) {
|
|
11
|
+
const newJson = buildJsonObject(columns, "NEW");
|
|
12
|
+
const oldJson = buildJsonObject(columns, "OLD");
|
|
13
|
+
const newPk = buildPkRef(pkColumns, "NEW");
|
|
14
|
+
const oldPk = buildPkRef(pkColumns, "OLD");
|
|
15
|
+
const replCols = replication ? ", node_id, tx_id, hlc" : "";
|
|
16
|
+
const replVals = replication ? ", '', '', ''" : "";
|
|
17
|
+
await conn.exec(`
|
|
18
|
+
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_insert"
|
|
19
|
+
AFTER INSERT ON "${table}"
|
|
20
|
+
BEGIN
|
|
21
|
+
INSERT INTO "${changesTable}" (table_name, operation, row_id, new_data${replCols})
|
|
22
|
+
VALUES ('${table}', 'INSERT', ${newPk}, ${newJson}${replVals});
|
|
23
|
+
END
|
|
24
|
+
`);
|
|
25
|
+
await conn.exec(`
|
|
26
|
+
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_update"
|
|
27
|
+
AFTER UPDATE ON "${table}"
|
|
28
|
+
BEGIN
|
|
29
|
+
INSERT INTO "${changesTable}" (table_name, operation, row_id, old_data, new_data${replCols})
|
|
30
|
+
VALUES ('${table}', 'UPDATE', ${newPk}, ${oldJson}, ${newJson}${replVals});
|
|
31
|
+
END
|
|
32
|
+
`);
|
|
33
|
+
await conn.exec(`
|
|
34
|
+
CREATE TRIGGER IF NOT EXISTS "_sirannon_trg_${table}_delete"
|
|
35
|
+
AFTER DELETE ON "${table}"
|
|
36
|
+
BEGIN
|
|
37
|
+
INSERT INTO "${changesTable}" (table_name, operation, row_id, old_data${replCols})
|
|
38
|
+
VALUES ('${table}', 'DELETE', ${oldPk}, ${oldJson}${replVals});
|
|
39
|
+
END
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
function buildPkRef(pkColumns, ref) {
|
|
43
|
+
if (pkColumns.length === 0) {
|
|
44
|
+
return `${ref}.rowid`;
|
|
45
|
+
}
|
|
46
|
+
if (pkColumns.length === 1) {
|
|
47
|
+
return `${ref}."${escId(pkColumns[0])}"`;
|
|
48
|
+
}
|
|
49
|
+
return pkColumns.map((col) => `${ref}."${escId(col)}"`).join(" || '-' || ");
|
|
50
|
+
}
|
|
51
|
+
function buildJsonObject(columns, ref) {
|
|
52
|
+
const pairs = columns.map((col) => {
|
|
53
|
+
const ident = `${ref}."${escId(col)}"`;
|
|
54
|
+
return `'${escStr(col)}', CASE typeof(${ident}) WHEN 'blob' THEN json(json_object('__sirannon_blob', hex(${ident}))) WHEN 'integer' THEN CASE WHEN ${ident} > ${SAFE_INT_BOUND_TEXT} OR ${ident} < -${SAFE_INT_BOUND_TEXT} THEN json(json_object('__sirannon_int', printf('%d', ${ident}))) ELSE ${ident} END ELSE ${ident} END`;
|
|
55
|
+
}).join(", ");
|
|
56
|
+
return `json_object(${pairs})`;
|
|
57
|
+
}
|
|
58
|
+
function escId(name) {
|
|
59
|
+
return name.replace(/"/g, '""');
|
|
60
|
+
}
|
|
61
|
+
function escStr(name) {
|
|
62
|
+
return name.replace(/'/g, "''");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/core/cdc/change-tracker.ts
|
|
66
|
+
var DEFAULT_RETENTION_MS = 36e5;
|
|
67
|
+
var DEFAULT_CHANGES_TABLE = "_sirannon_changes";
|
|
68
|
+
var DEFAULT_POLL_BATCH_SIZE = 1e3;
|
|
69
|
+
var IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
70
|
+
var ChangeTracker = class {
|
|
71
|
+
watched = /* @__PURE__ */ new Map();
|
|
72
|
+
lastSeq = 0n;
|
|
73
|
+
retentionMs;
|
|
74
|
+
changesTable;
|
|
75
|
+
pollBatchSize;
|
|
76
|
+
replication;
|
|
77
|
+
changesTableReady = false;
|
|
78
|
+
watchedTablesCache = null;
|
|
79
|
+
stmtCache = /* @__PURE__ */ new WeakMap();
|
|
80
|
+
pruneBoundary = null;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
this.retentionMs = options?.retention ?? DEFAULT_RETENTION_MS;
|
|
83
|
+
this.changesTable = options?.changesTable ?? DEFAULT_CHANGES_TABLE;
|
|
84
|
+
this.pollBatchSize = options?.pollBatchSize ?? DEFAULT_POLL_BATCH_SIZE;
|
|
85
|
+
this.replication = options?.replication ?? false;
|
|
86
|
+
this.assertIdentifier(this.changesTable, "changes table name");
|
|
87
|
+
}
|
|
88
|
+
async watch(conn, table) {
|
|
89
|
+
this.assertIdentifier(table, "table name");
|
|
90
|
+
await this.ensureChangesTable(conn);
|
|
91
|
+
const columns = await this.getColumns(conn, table);
|
|
92
|
+
if (columns.length === 0) {
|
|
93
|
+
throw new CDCError(`Table '${table}' does not exist or has no columns`);
|
|
94
|
+
}
|
|
95
|
+
for (const col of columns) {
|
|
96
|
+
this.assertIdentifier(col, `column name in table '${table}'`);
|
|
97
|
+
}
|
|
98
|
+
const pkColumns = await this.getPkColumns(conn, table);
|
|
99
|
+
const existing = this.watched.get(table);
|
|
100
|
+
if (existing) {
|
|
101
|
+
const same = existing.columns.length === columns.length && existing.columns.every((col, i) => col === columns[i]);
|
|
102
|
+
if (same) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await conn.transaction(async (txConn) => {
|
|
106
|
+
await this.dropTriggers(txConn, table);
|
|
107
|
+
await this.installTriggers(txConn, table, columns, pkColumns);
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
await this.installTriggers(conn, table, columns, pkColumns);
|
|
111
|
+
}
|
|
112
|
+
this.watched.set(table, { table, columns, pkColumns });
|
|
113
|
+
this.watchedTablesCache = null;
|
|
114
|
+
}
|
|
115
|
+
async unwatch(conn, table) {
|
|
116
|
+
if (!this.watched.has(table)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await this.dropTriggers(conn, table);
|
|
120
|
+
this.watched.delete(table);
|
|
121
|
+
this.watchedTablesCache = null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Rebuilds CDC triggers for every watched table directly on the supplied
|
|
125
|
+
* connection, without opening a nested transaction.
|
|
126
|
+
*
|
|
127
|
+
* Required when a caller has already issued `BEGIN` on `conn` and runs a
|
|
128
|
+
* DDL statement that changes a watched table's column list: the next DML
|
|
129
|
+
* inside the same transaction must see triggers compiled against the new
|
|
130
|
+
* columns, otherwise CDC `new_data` silently omits them. Callers that are
|
|
131
|
+
* not inside an active transaction may also use this method; CREATE
|
|
132
|
+
* TRIGGER and DROP TRIGGER statements are committed individually by the
|
|
133
|
+
* driver in that case.
|
|
134
|
+
*
|
|
135
|
+
* Failure semantics:
|
|
136
|
+
* - If a watched table no longer exists (e.g. it was just dropped by the
|
|
137
|
+
* DDL), it is skipped. The watched-map entry is left alone so a separate
|
|
138
|
+
* cleanup path can handle it; throwing here would roll back the user's
|
|
139
|
+
* transaction over a benign condition.
|
|
140
|
+
* - If reading column metadata succeeds but the column list is unchanged,
|
|
141
|
+
* no triggers are touched.
|
|
142
|
+
* - If reading column metadata succeeds and the column list differs from
|
|
143
|
+
* the cached one, the existing triggers are dropped and reinstalled on
|
|
144
|
+
* `conn`. The cached column list is updated on success.
|
|
145
|
+
* - Any other error (driver failure, identifier validation failure) is
|
|
146
|
+
* rethrown so the caller's transaction can roll back deterministically.
|
|
147
|
+
*/
|
|
148
|
+
async refreshAllTriggersUsingConnection(conn) {
|
|
149
|
+
const tables = Array.from(this.watched.keys());
|
|
150
|
+
let anyMutated = false;
|
|
151
|
+
for (const table of tables) {
|
|
152
|
+
const existing = this.watched.get(table);
|
|
153
|
+
if (!existing) continue;
|
|
154
|
+
const columns = await this.getColumns(conn, table);
|
|
155
|
+
if (columns.length === 0) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const col of columns) {
|
|
159
|
+
this.assertIdentifier(col, `column name in table '${table}'`);
|
|
160
|
+
}
|
|
161
|
+
const same = existing.columns.length === columns.length && existing.columns.every((col, i) => col === columns[i]);
|
|
162
|
+
if (same) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const pkColumns = await this.getPkColumns(conn, table);
|
|
166
|
+
await this.dropTriggers(conn, table);
|
|
167
|
+
await this.installTriggers(conn, table, columns, pkColumns);
|
|
168
|
+
this.watched.set(table, { table, columns, pkColumns });
|
|
169
|
+
anyMutated = true;
|
|
170
|
+
}
|
|
171
|
+
if (anyMutated) {
|
|
172
|
+
this.watchedTablesCache = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Removes the supplied tables from the watched map and drops any leftover
|
|
177
|
+
* CDC triggers carrying their identifier on the supplied connection.
|
|
178
|
+
*
|
|
179
|
+
* Intended to be called after a DDL transaction that dropped one or more
|
|
180
|
+
* watched tables has committed. On rollback the caller must not invoke
|
|
181
|
+
* this method; the rollback semantics rely on the caller discarding its
|
|
182
|
+
* captured drop list before reaching this call.
|
|
183
|
+
*
|
|
184
|
+
* Idempotent: tables not currently in the watched map are silently
|
|
185
|
+
* skipped. `dropCdcTriggers` issues `DROP TRIGGER IF EXISTS` so calling
|
|
186
|
+
* twice in succession produces the same state.
|
|
187
|
+
*
|
|
188
|
+
* Defence in depth: even after a `DROP TABLE`, the in-transaction trigger
|
|
189
|
+
* refresh path may have observed a freshly-created table of the same name
|
|
190
|
+
* (the DROP and CREATE happened inside the same transaction) and
|
|
191
|
+
* re-installed triggers compiled against the new schema. Those triggers
|
|
192
|
+
* are dropped here so the recreated table starts with a clean slate and
|
|
193
|
+
* the caller must explicitly `watch` it again.
|
|
194
|
+
*/
|
|
195
|
+
async pruneDroppedTables(conn, tables) {
|
|
196
|
+
let mutated = false;
|
|
197
|
+
for (const table of tables) {
|
|
198
|
+
if (!this.watched.has(table)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
await this.dropTriggers(conn, table);
|
|
202
|
+
this.watched.delete(table);
|
|
203
|
+
mutated = true;
|
|
204
|
+
}
|
|
205
|
+
if (mutated) {
|
|
206
|
+
this.watchedTablesCache = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async poll(conn) {
|
|
210
|
+
if (!this.changesTableReady) {
|
|
211
|
+
await this.detectChangesTable(conn);
|
|
212
|
+
if (!this.changesTableReady) {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const stmt = await this.getStmt(
|
|
217
|
+
conn,
|
|
218
|
+
"poll",
|
|
219
|
+
`SELECT seq, table_name, operation, row_id, changed_at, old_data, new_data
|
|
220
|
+
FROM "${this.changesTable}"
|
|
221
|
+
WHERE seq > ?
|
|
222
|
+
ORDER BY seq ASC
|
|
223
|
+
LIMIT ?`
|
|
224
|
+
);
|
|
225
|
+
const rows = await stmt.all(this.lastSeq.toString(), this.pollBatchSize);
|
|
226
|
+
if (rows.length === 0) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const events = [];
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
events.push({
|
|
232
|
+
type: row.operation.toLowerCase(),
|
|
233
|
+
table: row.table_name,
|
|
234
|
+
row: row.new_data ? decodeTaggedValues(JSON.parse(row.new_data)) : {},
|
|
235
|
+
oldRow: row.old_data ? decodeTaggedValues(JSON.parse(row.old_data)) : void 0,
|
|
236
|
+
seq: BigInt(row.seq),
|
|
237
|
+
timestamp: row.changed_at
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
this.lastSeq = BigInt(rows[rows.length - 1].seq);
|
|
241
|
+
return events;
|
|
242
|
+
}
|
|
243
|
+
async advanceToLatest(conn) {
|
|
244
|
+
if (!this.changesTableReady) {
|
|
245
|
+
await this.detectChangesTable(conn);
|
|
246
|
+
if (!this.changesTableReady) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const stmt = await this.getStmt(conn, "latest_seq", `SELECT MAX(seq) AS seq FROM "${this.changesTable}"`);
|
|
251
|
+
const row = await stmt.get();
|
|
252
|
+
const seq = row?.seq;
|
|
253
|
+
if (seq === void 0 || seq === null) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const latestSeq = typeof seq === "bigint" ? seq : BigInt(String(seq));
|
|
257
|
+
if (latestSeq > this.lastSeq) {
|
|
258
|
+
this.lastSeq = latestSeq;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async cleanup(conn) {
|
|
262
|
+
if (!this.changesTableReady) {
|
|
263
|
+
await this.detectChangesTable(conn);
|
|
264
|
+
if (!this.changesTableReady) {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const cutoff = Date.now() / 1e3 - this.retentionMs / 1e3;
|
|
269
|
+
const seqBound = this.computeSeqBound();
|
|
270
|
+
if (seqBound !== null) {
|
|
271
|
+
const stmt2 = await this.getStmt(
|
|
272
|
+
conn,
|
|
273
|
+
"cleanup_coordinated",
|
|
274
|
+
`DELETE FROM "${this.changesTable}" WHERE changed_at < ? AND seq <= ?`
|
|
275
|
+
);
|
|
276
|
+
const result2 = await stmt2.run(cutoff, seqBound.toString());
|
|
277
|
+
return result2.changes;
|
|
278
|
+
}
|
|
279
|
+
const stmt = await this.getStmt(conn, "cleanup", `DELETE FROM "${this.changesTable}" WHERE changed_at < ?`);
|
|
280
|
+
const result = await stmt.run(cutoff);
|
|
281
|
+
return result.changes;
|
|
282
|
+
}
|
|
283
|
+
setPruneBoundary(seq) {
|
|
284
|
+
this.pruneBoundary = seq;
|
|
285
|
+
}
|
|
286
|
+
clearPruneBoundary() {
|
|
287
|
+
this.pruneBoundary = null;
|
|
288
|
+
}
|
|
289
|
+
get watchedTables() {
|
|
290
|
+
if (!this.watchedTablesCache) {
|
|
291
|
+
this.watchedTablesCache = new Set(this.watched.keys());
|
|
292
|
+
}
|
|
293
|
+
return this.watchedTablesCache;
|
|
294
|
+
}
|
|
295
|
+
computeSeqBound() {
|
|
296
|
+
const boundary = this.pruneBoundary;
|
|
297
|
+
if (this.lastSeq > 0n && boundary !== null) {
|
|
298
|
+
return this.lastSeq < boundary ? this.lastSeq : boundary;
|
|
299
|
+
}
|
|
300
|
+
if (boundary !== null) {
|
|
301
|
+
return boundary;
|
|
302
|
+
}
|
|
303
|
+
if (this.lastSeq > 0n) {
|
|
304
|
+
return this.lastSeq;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
assertIdentifier(name, label) {
|
|
309
|
+
if (!IDENTIFIER_RE.test(name)) {
|
|
310
|
+
throw new CDCError(
|
|
311
|
+
`Invalid ${label} '${name}': must contain only letters, digits, and underscores, and start with a letter or underscore`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async detectChangesTable(conn) {
|
|
316
|
+
const stmt = await conn.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?");
|
|
317
|
+
const row = await stmt.get(this.changesTable);
|
|
318
|
+
if (row) {
|
|
319
|
+
this.changesTableReady = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async ensureChangesTable(conn) {
|
|
323
|
+
if (this.changesTableReady) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (this.replication) {
|
|
327
|
+
await conn.exec(`
|
|
328
|
+
CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
329
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
330
|
+
table_name TEXT NOT NULL,
|
|
331
|
+
operation TEXT NOT NULL,
|
|
332
|
+
row_id TEXT NOT NULL,
|
|
333
|
+
changed_at REAL NOT NULL DEFAULT (unixepoch('subsec')),
|
|
334
|
+
old_data TEXT,
|
|
335
|
+
new_data TEXT,
|
|
336
|
+
node_id TEXT NOT NULL DEFAULT '',
|
|
337
|
+
tx_id TEXT NOT NULL DEFAULT '',
|
|
338
|
+
hlc TEXT NOT NULL DEFAULT ''
|
|
339
|
+
)`);
|
|
340
|
+
await conn.exec(
|
|
341
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.changesTable}_changed_at" ON "${this.changesTable}" (changed_at)`
|
|
342
|
+
);
|
|
343
|
+
await conn.exec(
|
|
344
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.changesTable}_node_id" ON "${this.changesTable}" (node_id)`
|
|
345
|
+
);
|
|
346
|
+
await conn.exec(`CREATE INDEX IF NOT EXISTS "idx_${this.changesTable}_hlc" ON "${this.changesTable}" (hlc)`);
|
|
347
|
+
} else {
|
|
348
|
+
await conn.exec(`
|
|
349
|
+
CREATE TABLE IF NOT EXISTS "${this.changesTable}" (
|
|
350
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
351
|
+
table_name TEXT NOT NULL,
|
|
352
|
+
operation TEXT NOT NULL,
|
|
353
|
+
row_id TEXT NOT NULL,
|
|
354
|
+
changed_at REAL NOT NULL DEFAULT (unixepoch('subsec')),
|
|
355
|
+
old_data TEXT,
|
|
356
|
+
new_data TEXT
|
|
357
|
+
)`);
|
|
358
|
+
await conn.exec(
|
|
359
|
+
`CREATE INDEX IF NOT EXISTS "idx_${this.changesTable}_changed_at" ON "${this.changesTable}" (changed_at)`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
this.changesTableReady = true;
|
|
363
|
+
}
|
|
364
|
+
async getColumns(conn, table) {
|
|
365
|
+
const stmt = await conn.prepare(`PRAGMA table_info("${table}")`);
|
|
366
|
+
const info = await stmt.all();
|
|
367
|
+
return info.map((col) => col.name);
|
|
368
|
+
}
|
|
369
|
+
async getPkColumns(conn, table) {
|
|
370
|
+
const stmt = await conn.prepare(`PRAGMA table_info("${table}")`);
|
|
371
|
+
const info = await stmt.all();
|
|
372
|
+
return info.filter((col) => col.pk > 0).sort((a, b) => a.pk - b.pk).map((col) => col.name);
|
|
373
|
+
}
|
|
374
|
+
async dropTriggers(conn, table) {
|
|
375
|
+
await dropCdcTriggers(conn, table);
|
|
376
|
+
}
|
|
377
|
+
async getStmt(conn, key, sql) {
|
|
378
|
+
let stmts = this.stmtCache.get(conn);
|
|
379
|
+
if (!stmts) {
|
|
380
|
+
stmts = /* @__PURE__ */ new Map();
|
|
381
|
+
this.stmtCache.set(conn, stmts);
|
|
382
|
+
}
|
|
383
|
+
const existing = stmts.get(key);
|
|
384
|
+
if (existing) return existing;
|
|
385
|
+
const pending = conn.prepare(sql);
|
|
386
|
+
stmts.set(key, pending);
|
|
387
|
+
try {
|
|
388
|
+
return await pending;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
stmts.delete(key);
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async installTriggers(conn, table, columns, pkColumns) {
|
|
395
|
+
await installCdcTriggers(conn, this.changesTable, table, columns, pkColumns, this.replication);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// src/core/cdc/subscription.ts
|
|
400
|
+
var SubscriptionManager = class {
|
|
401
|
+
nextId = 1;
|
|
402
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
403
|
+
byTable = /* @__PURE__ */ new Map();
|
|
404
|
+
subscribe(table, filter, callback) {
|
|
405
|
+
const id = this.nextId++;
|
|
406
|
+
this.subscriptions.set(id, { id, table, filter, callback });
|
|
407
|
+
let tableSet = this.byTable.get(table);
|
|
408
|
+
if (!tableSet) {
|
|
409
|
+
tableSet = /* @__PURE__ */ new Set();
|
|
410
|
+
this.byTable.set(table, tableSet);
|
|
411
|
+
}
|
|
412
|
+
tableSet.add(id);
|
|
413
|
+
return {
|
|
414
|
+
unsubscribe: () => {
|
|
415
|
+
this.subscriptions.delete(id);
|
|
416
|
+
const set = this.byTable.get(table);
|
|
417
|
+
if (set) {
|
|
418
|
+
set.delete(id);
|
|
419
|
+
if (set.size === 0) {
|
|
420
|
+
this.byTable.delete(table);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
dispatch(events) {
|
|
427
|
+
for (const event of events) {
|
|
428
|
+
const ids = this.byTable.get(event.table);
|
|
429
|
+
if (!ids) continue;
|
|
430
|
+
for (const id of ids) {
|
|
431
|
+
const sub = this.subscriptions.get(id);
|
|
432
|
+
if (!sub) continue;
|
|
433
|
+
if (sub.filter && !matchesFilter(event, sub.filter)) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
sub.callback(event);
|
|
438
|
+
} catch {
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
get size() {
|
|
444
|
+
return this.subscriptions.size;
|
|
445
|
+
}
|
|
446
|
+
subscriberCount(table) {
|
|
447
|
+
return this.byTable.get(table)?.size ?? 0;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
var SubscriptionBuilderImpl = class {
|
|
451
|
+
constructor(table, manager) {
|
|
452
|
+
this.table = table;
|
|
453
|
+
this.manager = manager;
|
|
454
|
+
}
|
|
455
|
+
conditions;
|
|
456
|
+
filter(conditions) {
|
|
457
|
+
this.conditions = { ...this.conditions, ...conditions };
|
|
458
|
+
return this;
|
|
459
|
+
}
|
|
460
|
+
subscribe(callback) {
|
|
461
|
+
return this.manager.subscribe(this.table, this.conditions, callback);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
function startPolling(conn, tracker, manager, intervalMs, onError) {
|
|
465
|
+
let consecutiveErrors = 0;
|
|
466
|
+
let tickCount = 0;
|
|
467
|
+
let polling = false;
|
|
468
|
+
const MAX_CONSECUTIVE_ERRORS = 10;
|
|
469
|
+
const CLEANUP_INTERVAL_TICKS = 100;
|
|
470
|
+
const tick = async () => {
|
|
471
|
+
if (manager.size === 0) return;
|
|
472
|
+
if (polling) return;
|
|
473
|
+
polling = true;
|
|
474
|
+
try {
|
|
475
|
+
const events = await tracker.poll(conn);
|
|
476
|
+
if (events.length > 0) {
|
|
477
|
+
manager.dispatch(events);
|
|
478
|
+
}
|
|
479
|
+
consecutiveErrors = 0;
|
|
480
|
+
tickCount++;
|
|
481
|
+
if (tickCount >= CLEANUP_INTERVAL_TICKS) {
|
|
482
|
+
tickCount = 0;
|
|
483
|
+
await tracker.cleanup(conn);
|
|
484
|
+
}
|
|
485
|
+
} catch (err) {
|
|
486
|
+
consecutiveErrors++;
|
|
487
|
+
if (onError) {
|
|
488
|
+
onError(err instanceof Error ? err : new Error(String(err)));
|
|
489
|
+
}
|
|
490
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
491
|
+
stop();
|
|
492
|
+
}
|
|
493
|
+
} finally {
|
|
494
|
+
polling = false;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const interval = setInterval(tick, intervalMs);
|
|
498
|
+
interval.unref?.();
|
|
499
|
+
const stop = () => {
|
|
500
|
+
clearInterval(interval);
|
|
501
|
+
};
|
|
502
|
+
return stop;
|
|
503
|
+
}
|
|
504
|
+
function matchesFilter(event, filter) {
|
|
505
|
+
const target = event.type === "delete" ? event.oldRow ?? {} : event.row;
|
|
506
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
507
|
+
if (target[key] !== value) {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export { ChangeTracker, SubscriptionBuilderImpl, SubscriptionManager, startPolling };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { SirannonError } from './chunk-O7BHI3CF.mjs';
|
|
2
|
+
|
|
3
|
+
// src/replication/errors.ts
|
|
4
|
+
var ReplicationError = class extends SirannonError {
|
|
5
|
+
constructor(message, code = "REPLICATION_ERROR", details) {
|
|
6
|
+
super(message, code);
|
|
7
|
+
this.details = details;
|
|
8
|
+
this.name = "ReplicationError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var ConflictError = class extends ReplicationError {
|
|
12
|
+
constructor(message, table, rowId) {
|
|
13
|
+
super(message, "CONFLICT_ERROR");
|
|
14
|
+
this.table = table;
|
|
15
|
+
this.rowId = rowId;
|
|
16
|
+
this.name = "ConflictError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var TransportError = class extends ReplicationError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message, "TRANSPORT_ERROR");
|
|
22
|
+
this.name = "TransportError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var BatchValidationError = class extends ReplicationError {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message, "BATCH_VALIDATION_ERROR");
|
|
28
|
+
this.name = "BatchValidationError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var WriteConcernError = class extends ReplicationError {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message, "WRITE_CONCERN_ERROR");
|
|
34
|
+
this.name = "WriteConcernError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var ReadConcernError = class extends ReplicationError {
|
|
38
|
+
constructor(message, details) {
|
|
39
|
+
super(message, "READ_CONCERN_ERROR", details);
|
|
40
|
+
this.name = "ReadConcernError";
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var TopologyError = class extends ReplicationError {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message, "TOPOLOGY_ERROR");
|
|
46
|
+
this.name = "TopologyError";
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var CoordinatorError = class extends ReplicationError {
|
|
50
|
+
constructor(message, details) {
|
|
51
|
+
super(message, "COORDINATOR_UNAVAILABLE", details);
|
|
52
|
+
this.name = "CoordinatorError";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
var AuthorityError = class extends ReplicationError {
|
|
56
|
+
constructor(message, code = "AUTHORITY_LOST", details) {
|
|
57
|
+
super(message, code, details);
|
|
58
|
+
this.name = "AuthorityError";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var StalePrimaryError = class extends AuthorityError {
|
|
62
|
+
constructor(message, details) {
|
|
63
|
+
super(message, "STALE_PRIMARY", details);
|
|
64
|
+
this.name = "StalePrimaryError";
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var FailoverError = class extends ReplicationError {
|
|
68
|
+
constructor(message, code = "NO_SAFE_PRIMARY", details) {
|
|
69
|
+
super(message, code, details);
|
|
70
|
+
this.name = "FailoverError";
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var NoSafePrimaryError = class extends FailoverError {
|
|
74
|
+
constructor(message, details) {
|
|
75
|
+
super(message, "NO_SAFE_PRIMARY", details);
|
|
76
|
+
this.name = "NoSafePrimaryError";
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
var NodeNotInSyncError = class extends ReplicationError {
|
|
80
|
+
constructor(message, details) {
|
|
81
|
+
super(message, "NODE_NOT_IN_SYNC", details);
|
|
82
|
+
this.name = "NodeNotInSyncError";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var NodeDrainingError = class extends ReplicationError {
|
|
86
|
+
constructor(message, details) {
|
|
87
|
+
super(message, "NODE_DRAINING", details);
|
|
88
|
+
this.name = "NodeDrainingError";
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
var ProtocolVersionMismatchError = class extends ReplicationError {
|
|
92
|
+
constructor(message, details) {
|
|
93
|
+
super(message, "PROTOCOL_VERSION_MISMATCH", details);
|
|
94
|
+
this.name = "ProtocolVersionMismatchError";
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var UnsafeRecoveryRequiredError = class extends FailoverError {
|
|
98
|
+
constructor(message, details) {
|
|
99
|
+
super(message, "UNSAFE_RECOVERY_REQUIRED", details);
|
|
100
|
+
this.name = "UnsafeRecoveryRequiredError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
var SyncError = class extends ReplicationError {
|
|
104
|
+
constructor(message, requestId) {
|
|
105
|
+
super(message, "SYNC_ERROR");
|
|
106
|
+
this.requestId = requestId;
|
|
107
|
+
this.name = "SyncError";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export { AuthorityError, BatchValidationError, ConflictError, CoordinatorError, FailoverError, NoSafePrimaryError, NodeDrainingError, NodeNotInSyncError, ProtocolVersionMismatchError, ReadConcernError, ReplicationError, StalePrimaryError, SyncError, TopologyError, TransportError, UnsafeRecoveryRequiredError, WriteConcernError };
|