@profullstack/libsql-pg 0.1.0

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/src/copy.js ADDED
@@ -0,0 +1,614 @@
1
+ import pg from 'pg';
2
+
3
+ import { connectionSettings } from './client.js';
4
+ import { codeMask, quoteIdent, splitTopLevel, unquote } from './sqlparse.js';
5
+
6
+ /**
7
+ * Copy a Turso / libSQL / SQLite-file database into Postgres.
8
+ *
9
+ * Generalised from the loader that moved rssamplifier.com (46 GB, 15.5M
10
+ * rows in one table) on 2026-09-25, with its lessons kept:
11
+ *
12
+ * - rows are paged out of SQLite by rowid, the physical order, in batches;
13
+ * - the first and last rowid are two separate `order by rowid limit 1`
14
+ * lookups: `select min(rowid), max(rowid)` in one query makes SQLite scan
15
+ * the whole table, which over the network is a stall;
16
+ * - Turso drops the odd request under load, so every read has a deadline
17
+ * (600 s) and retries;
18
+ * - `truncate only`, never `cascade`: a cascade on a parent table silently
19
+ * empties every table that references it;
20
+ * - `--upsert` refreshes a parent in place through a temp table and
21
+ * `on conflict (pk) do update`, with identity columns kept out of the
22
+ * SET list (Postgres refuses to update a GENERATED ALWAYS identity) and
23
+ * rows the source deleted removed too;
24
+ * - identity/serial sequences are moved past the copied ids, or the app's
25
+ * first insert collides with a copied row;
26
+ * - `--verify` counts rows on both sides before anyone flips a switch.
27
+ *
28
+ * Postgres holds the schema already (run `libsql-pg convert-schema` first).
29
+ * Values are coerced to the target column's type from information_schema,
30
+ * because SQLite is dynamically typed: a text column can hold a number and
31
+ * an integer column an empty string.
32
+ *
33
+ * @typedef {{
34
+ * from: string,
35
+ * token?: string,
36
+ * to: string,
37
+ * tables?: string[],
38
+ * exclude?: string[],
39
+ * truncate?: boolean,
40
+ * upsert?: boolean,
41
+ * batch?: number,
42
+ * workers?: number,
43
+ * readTimeoutMs?: number,
44
+ * retries?: number,
45
+ * dryRun?: boolean,
46
+ * log?: (line: string) => void,
47
+ * }} CopyOptions
48
+ *
49
+ * @typedef {{ table: string, mode: string, rows: number, seconds: number, skipped?: string }} TableReport
50
+ */
51
+
52
+ const SQLITE_INTERNAL = /^sqlite_/i;
53
+
54
+ /**
55
+ * Tables listed in sqlite_master, minus SQLite's own and the FTS shadow
56
+ * tables (`x_data`, `x_idx`, `x_content`, `x_docsize`, `x_config` for each
57
+ * virtual table `x`, which are not tables the app wrote to).
58
+ *
59
+ * @param {Array<{ name: string, sql: string | null, type?: string }>} master rows of sqlite_master
60
+ * @returns {Array<{ name: string, sql: string }>}
61
+ */
62
+ export function userTables(master) {
63
+ const virtual = new Set(
64
+ master.filter((r) => r.sql && /^\s*create\s+virtual\s+table/i.test(r.sql)).map((r) => r.name),
65
+ );
66
+ const shadow = new Set();
67
+ for (const v of virtual) for (const s of ['data', 'idx', 'content', 'docsize', 'config', 'segments', 'segdir', 'stat']) shadow.add(`${v}_${s}`);
68
+ return master
69
+ .filter((r) => (r.type ?? 'table') === 'table')
70
+ .filter((r) => !SQLITE_INTERNAL.test(r.name) && !virtual.has(r.name) && !shadow.has(r.name) && r.sql)
71
+ .map((r) => ({ name: r.name, sql: /** @type {string} */ (r.sql) }));
72
+ }
73
+
74
+ /**
75
+ * Tables a CREATE TABLE references (its FK parents), from its SQL.
76
+ * @param {string} sql
77
+ * @returns {string[]}
78
+ */
79
+ export function referencedTables(sql) {
80
+ const mask = codeMask(sql);
81
+ const out = new Set();
82
+ const re = /\breferences\s+("[^"]*"|`[^`]*`|\[[^\]]*\]|[A-Za-z_][A-Za-z0-9_]*)/gi;
83
+ // Quoted names are blanked in the mask; take them from the text by index.
84
+ for (let m = re.exec(mask); m; m = re.exec(mask)) {
85
+ const start = m.index + m[0].length - m[1].length;
86
+ const raw = sql.slice(start, m.index + m[0].length);
87
+ const q = raw[0];
88
+ let name;
89
+ if (q === '"' || q === '`' || q === '[') {
90
+ const end = sql.indexOf(q === '[' ? ']' : q, start + 1);
91
+ name = sql.slice(start + 1, end === -1 ? undefined : end);
92
+ } else name = unquote(raw);
93
+ out.add(name);
94
+ }
95
+ return [...out];
96
+ }
97
+
98
+ /**
99
+ * Order tables so FK parents load before their children. Tables in a cycle
100
+ * (or self-referencing) fall to the end in alphabetical order, and the
101
+ * caller loads with constraints deferred.
102
+ *
103
+ * @param {Array<{ name: string, sql: string }>} tables
104
+ * @returns {{ order: string[], cyclic: string[] }}
105
+ */
106
+ export function orderTables(tables) {
107
+ const byName = new Map(tables.map((t) => [t.name, t]));
108
+ const lower = new Map(tables.map((t) => [t.name.toLowerCase(), t.name]));
109
+ const deps = new Map();
110
+ for (const t of tables) {
111
+ const parents = referencedTables(t.sql)
112
+ .map((p) => lower.get(p.toLowerCase()))
113
+ .filter((p) => p && p !== t.name && byName.has(p));
114
+ deps.set(t.name, new Set(parents));
115
+ }
116
+ const order = [];
117
+ const placed = new Set();
118
+ let remaining = [...tables.map((t) => t.name)].sort();
119
+ for (;;) {
120
+ const ready = remaining.filter((n) => [...deps.get(n)].every((p) => placed.has(p)));
121
+ if (!ready.length) break;
122
+ for (const n of ready) {
123
+ order.push(n);
124
+ placed.add(n);
125
+ }
126
+ remaining = remaining.filter((n) => !placed.has(n));
127
+ }
128
+ return { order: [...order, ...remaining], cyclic: remaining };
129
+ }
130
+
131
+ /**
132
+ * Coerce a SQLite value to what Postgres will accept for the column type.
133
+ *
134
+ * @param {unknown} value
135
+ * @param {string} dataType information_schema.columns.data_type
136
+ * @returns {unknown} a value pg can send, or null
137
+ */
138
+ export function coerce(value, dataType) {
139
+ if (value === null || value === undefined) return null;
140
+ if (value instanceof ArrayBuffer) value = Buffer.from(value);
141
+ else if (ArrayBuffer.isView(value) && !(value instanceof Buffer)) value = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
142
+ switch (dataType) {
143
+ case 'bigint':
144
+ case 'integer':
145
+ case 'smallint': {
146
+ if (value === '' || value === false) return null;
147
+ if (value === true) return 1;
148
+ if (typeof value === 'bigint') return value.toString();
149
+ const n = Number(value);
150
+ return Number.isFinite(n) ? String(Math.trunc(n)) : null;
151
+ }
152
+ case 'double precision':
153
+ case 'real':
154
+ case 'numeric': {
155
+ if (value === '') return null;
156
+ if (typeof value === 'bigint') return value.toString();
157
+ const n = Number(value);
158
+ return Number.isFinite(n) ? String(n) : null;
159
+ }
160
+ case 'boolean': {
161
+ if (typeof value === 'boolean') return value;
162
+ if (typeof value === 'bigint') return value !== 0n;
163
+ if (typeof value === 'number') return value !== 0;
164
+ const s = String(value).trim().toLowerCase();
165
+ if (s === '' ) return null;
166
+ if (s === '1' || s === 'true' || s === 't' || s === 'yes' || s === 'y') return true;
167
+ if (s === '0' || s === 'false' || s === 'f' || s === 'no' || s === 'n') return false;
168
+ return null;
169
+ }
170
+ case 'timestamp with time zone':
171
+ case 'timestamp without time zone':
172
+ case 'date': {
173
+ if (value === '') return null;
174
+ if (typeof value === 'bigint') value = Number(value);
175
+ if (typeof value === 'number' || (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value.trim()))) {
176
+ // A unix epoch: seconds unless it is clearly milliseconds.
177
+ const n = Number(value);
178
+ const ms = Math.abs(n) >= 1e11 ? n : n * 1000;
179
+ const d = new Date(ms);
180
+ if (Number.isNaN(d.getTime())) return null;
181
+ return dataType === 'date' ? d.toISOString().slice(0, 10) : d.toISOString();
182
+ }
183
+ return typeof value === 'string' ? value : String(value);
184
+ }
185
+ case 'bytea': {
186
+ if (Buffer.isBuffer(value)) return value;
187
+ return Buffer.from(String(value), 'utf8');
188
+ }
189
+ case 'json':
190
+ case 'jsonb': {
191
+ if (typeof value === 'string') return value === '' ? null : value;
192
+ if (Buffer.isBuffer(value)) return value.toString('utf8');
193
+ return JSON.stringify(value);
194
+ }
195
+ default: {
196
+ // text, uuid, character varying, ...: Postgres text cannot hold NUL.
197
+ if (Buffer.isBuffer(value)) return value.toString('utf8').replace(/\0/g, '');
198
+ if (typeof value === 'string') return value.includes('\0') ? value.replace(/\0/g, '') : value;
199
+ if (typeof value === 'bigint') return value.toString();
200
+ return String(value);
201
+ }
202
+ }
203
+ }
204
+
205
+ /** The pg array element type for an information_schema data_type. */
206
+ function arrayType(dataType) {
207
+ switch (dataType) {
208
+ case 'character varying':
209
+ return 'text';
210
+ case 'timestamp with time zone':
211
+ return 'timestamptz';
212
+ case 'timestamp without time zone':
213
+ return 'timestamp';
214
+ case 'double precision':
215
+ case 'bigint':
216
+ case 'integer':
217
+ case 'smallint':
218
+ case 'real':
219
+ case 'numeric':
220
+ case 'boolean':
221
+ case 'date':
222
+ case 'bytea':
223
+ case 'json':
224
+ case 'jsonb':
225
+ case 'uuid':
226
+ case 'text':
227
+ return dataType;
228
+ case 'ARRAY':
229
+ case 'USER-DEFINED':
230
+ return 'text';
231
+ default:
232
+ return 'text';
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Build the batched insert: one array parameter per column, unnested.
238
+ *
239
+ * @param {string} table
240
+ * @param {Array<{ column_name: string, data_type: string, is_identity: string }>} cols
241
+ * @returns {string}
242
+ */
243
+ export function insertSql(table, cols) {
244
+ const names = cols.map((c) => quoteIdent(c.column_name)).join(', ');
245
+ const arrays = cols.map((c, i) => `$${i + 1}::${arrayType(c.data_type)}[]`).join(', ');
246
+ const overriding = cols.some((c) => c.is_identity === 'YES') ? ' overriding system value' : '';
247
+ return `insert into ${quoteIdent(table)} (${names})${overriding} select * from unnest(${arrays})`;
248
+ }
249
+
250
+ /**
251
+ * @param {CopyOptions} opts
252
+ */
253
+ export async function copyDatabase(opts) {
254
+ const log = opts.log ?? ((line) => console.log(`${new Date().toISOString().slice(11, 19)} ${line}`));
255
+ const BATCH = Math.max(1, opts.batch ?? 2000);
256
+ const WORKERS = Math.max(1, opts.workers ?? 1);
257
+ const RETRIES = opts.retries ?? 5;
258
+ const TIMEOUT = opts.readTimeoutMs ?? 600_000;
259
+
260
+ let libsql;
261
+ try {
262
+ libsql = await import('@libsql/client');
263
+ } catch {
264
+ throw new Error('`libsql-pg copy` reads the source with @libsql/client; install it next to this package: npm i @libsql/client');
265
+ }
266
+ const src = libsql.createClient({
267
+ url: opts.from,
268
+ authToken: opts.token,
269
+ intMode: 'number',
270
+ fetch: (input, init = {}) =>
271
+ fetch(input, { ...init, signal: AbortSignal.any([init.signal, AbortSignal.timeout(TIMEOUT)].filter(Boolean)) }),
272
+ });
273
+ /** A source read with retries: Turso drops the odd request under load. */
274
+ async function read(statement) {
275
+ for (let i = 1; ; i++) {
276
+ try {
277
+ return await src.execute(statement);
278
+ } catch (err) {
279
+ if (i > RETRIES) throw err;
280
+ log(`read failed (${String(err?.message ?? err).slice(0, 80)}); retry ${i}/${RETRIES}`);
281
+ await new Promise((r) => setTimeout(r, 2_000 * i));
282
+ }
283
+ }
284
+ }
285
+
286
+ const { connectionString, ssl } = connectionSettings(opts.to, undefined);
287
+ const dst = new pg.Pool({ connectionString, ssl, max: WORKERS + 2 });
288
+ /** @type {TableReport[]} */
289
+ const reports = [];
290
+
291
+ try {
292
+ const master = (await read("select name, type, sql from sqlite_master where type = 'table' order by name")).rows.map((r) => ({
293
+ name: String(r.name),
294
+ type: String(r.type),
295
+ sql: r.sql === null ? null : String(r.sql),
296
+ }));
297
+ let tables = userTables(master);
298
+ if (opts.tables?.length) {
299
+ const want = new Set(opts.tables.map((t) => t.toLowerCase()));
300
+ tables = tables.filter((t) => want.has(t.name.toLowerCase()));
301
+ for (const w of opts.tables) if (!tables.some((t) => t.name.toLowerCase() === w.toLowerCase())) log(`${w}: not found in the source, skipped`);
302
+ }
303
+ if (opts.exclude?.length) {
304
+ const skip = new Set(opts.exclude.map((t) => t.toLowerCase()));
305
+ tables = tables.filter((t) => !skip.has(t.name.toLowerCase()));
306
+ }
307
+ const { order, cyclic } = orderTables(tables);
308
+ if (cyclic.length) log(`FK cycle or self-reference among: ${cyclic.join(', ')} (loaded last, constraints deferred)`);
309
+ log(`${order.length} table(s): ${order.join(', ')}`);
310
+ if (opts.dryRun) {
311
+ for (const t of order) reports.push({ table: t, mode: 'dry-run', rows: 0, seconds: 0 });
312
+ return reports;
313
+ }
314
+
315
+ // Loading with FK checks off needs a superuser (session_replication_role);
316
+ // without one, parents-first ordering plus deferred constraints must do.
317
+ const probe = await dst.connect();
318
+ let replica = false;
319
+ try {
320
+ await probe.query("set session_replication_role = 'replica'");
321
+ replica = true;
322
+ } catch {
323
+ log('no permission for session_replication_role = replica; relying on table order and deferred constraints');
324
+ } finally {
325
+ probe.release();
326
+ }
327
+
328
+ for (const table of order) {
329
+ const t0 = Date.now();
330
+ try {
331
+ const r = await loadTable(table);
332
+ reports.push({ ...r, seconds: Math.round((Date.now() - t0) / 1000) });
333
+ } catch (err) {
334
+ reports.push({ table, mode: 'error', rows: 0, seconds: Math.round((Date.now() - t0) / 1000), skipped: String(err?.message ?? err) });
335
+ log(`${table}: FAILED ${String(err?.message ?? err)}`);
336
+ throw err;
337
+ }
338
+ }
339
+ log('all tables loaded');
340
+ return reports;
341
+
342
+ // ---------------------------------------------------------------- tables
343
+
344
+ async function pgColumns(table) {
345
+ const { rows } = await dst.query(
346
+ `select column_name, data_type, is_generated, is_identity, column_default
347
+ from information_schema.columns
348
+ where table_schema = current_schema() and table_name = $1
349
+ order by ordinal_position`,
350
+ [table],
351
+ );
352
+ return rows.filter((r) => r.is_generated !== 'ALWAYS');
353
+ }
354
+
355
+ async function sqliteColumns(table) {
356
+ const { rows } = await read(`pragma table_info(${quoteIdent(table)})`);
357
+ return rows.map((r) => String(r.name));
358
+ }
359
+
360
+ async function primaryKey(table) {
361
+ const { rows } = await dst.query(
362
+ `select a.attname
363
+ from pg_index i
364
+ join lateral unnest(i.indkey) with ordinality as x(attnum, ord) on true
365
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = x.attnum
366
+ where i.indrelid = to_regclass($1) and i.indisprimary
367
+ order by x.ord`,
368
+ [quoteIdent(table)],
369
+ );
370
+ return rows.map((r) => r.attname);
371
+ }
372
+
373
+ async function session(conn) {
374
+ if (replica) await conn.query("set session_replication_role = 'replica'");
375
+ else await conn.query('set constraints all deferred').catch(() => {});
376
+ }
377
+
378
+ /** Insert rows in one batch, coerced to the target types. */
379
+ async function insertBatch(conn, table, cols, rows) {
380
+ if (!rows.length) return 0;
381
+ const arrays = cols.map((c) => rows.map((r) => coerce(r[c.column_name], c.data_type)));
382
+ const res = await conn.query({ text: insertSql(table, cols), values: arrays });
383
+ return res.rowCount ?? rows.length;
384
+ }
385
+
386
+ async function resetSequences(conn, table, pgCols) {
387
+ const serial = pgCols.filter((c) => c.is_identity === 'YES' || /^nextval\(/.test(c.column_default ?? ''));
388
+ for (const c of serial) {
389
+ await conn.query(
390
+ `select setval(pg_get_serial_sequence($1, $2), greatest(coalesce((select max(${quoteIdent(c.column_name)}) from ${quoteIdent(table)}), 0), 1), coalesce((select max(${quoteIdent(c.column_name)}) from ${quoteIdent(table)}), 0) > 0)`,
391
+ [quoteIdent(table), c.column_name],
392
+ );
393
+ }
394
+ }
395
+
396
+ /** Does the source table have a rowid we can page on? */
397
+ async function hasRowid(table) {
398
+ try {
399
+ await read(`select rowid from ${quoteIdent(table)} limit 1`);
400
+ return true;
401
+ } catch {
402
+ return false; // WITHOUT ROWID table
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Stream a table's rows in rowid order, calling `sink(rows)` per batch.
408
+ * Two lookups for the bounds, never `min(rowid), max(rowid)` together.
409
+ */
410
+ async function streamRows(table, sink, { workers = 1 } = {}) {
411
+ const q = quoteIdent(table);
412
+ if (!(await hasRowid(table))) {
413
+ let offset = 0;
414
+ for (;;) {
415
+ const { rows } = await read({ sql: `select * from ${q} limit ? offset ?`, args: [BATCH, offset] });
416
+ if (!rows.length) break;
417
+ await sink(rows, 0);
418
+ offset += rows.length;
419
+ if (rows.length < BATCH) break;
420
+ }
421
+ return;
422
+ }
423
+ const first = (await read(`select rowid as r from ${q} order by rowid limit 1`)).rows[0];
424
+ if (!first) return;
425
+ const last = (await read(`select rowid as r from ${q} order by rowid desc limit 1`)).rows[0];
426
+ const lo = Number(first.r);
427
+ const hi = Number(last.r);
428
+ const span = hi - lo + 1;
429
+ const n = Math.max(1, Math.min(workers, Math.ceil(span / BATCH)));
430
+ const slice = Math.ceil(span / n);
431
+ const select = `select rowid as __rowid, * from ${q} where rowid > ? and rowid <= ? order by rowid limit ?`;
432
+ const runSlice = async (from, to, idx) => {
433
+ let cursor = from - 1;
434
+ for (;;) {
435
+ const { rows } = await read({ sql: select, args: [cursor, to, BATCH] });
436
+ if (!rows.length) break;
437
+ await sink(rows, idx);
438
+ cursor = Number(rows[rows.length - 1].__rowid);
439
+ if (rows.length < BATCH) break;
440
+ }
441
+ };
442
+ const jobs = [];
443
+ for (let i = 0; i < n; i++) jobs.push(runSlice(lo + i * slice, Math.min(hi, lo + (i + 1) * slice - 1), i));
444
+ await Promise.all(jobs);
445
+ }
446
+
447
+ /** @returns {Promise<TableReport>} */
448
+ async function loadTable(table) {
449
+ const pgCols = await pgColumns(table);
450
+ if (!pgCols.length) {
451
+ log(`${table}: not in Postgres, skipped (run convert-schema and apply it first)`);
452
+ return { table, mode: 'skipped', rows: 0, seconds: 0, skipped: 'not in Postgres' };
453
+ }
454
+ const srcCols = new Set(await sqliteColumns(table));
455
+ // Columns present on both sides. A Postgres identity column named rowid
456
+ // takes SQLite's implicit rowid so cursors that page on it keep working.
457
+ const cols = pgCols.filter((c) => srcCols.has(c.column_name) || c.column_name === 'rowid');
458
+ const takesRowid = !srcCols.has('rowid') && cols.some((c) => c.column_name === 'rowid');
459
+ const missing = pgCols.filter((c) => !cols.includes(c)).map((c) => c.column_name);
460
+ if (missing.length) log(`${table}: Postgres columns not in the source, left to their defaults: ${missing.join(', ')}`);
461
+
462
+ const conn = await dst.connect();
463
+ try {
464
+ await session(conn);
465
+ if (opts.upsert) return await upsertTable(conn, table, cols, pgCols, takesRowid);
466
+ const existing = Number((await conn.query(`select count(*) from ${quoteIdent(table)}`)).rows[0].count);
467
+ if (opts.truncate) {
468
+ // `only`, never `cascade`: a cascade on a parent silently empties
469
+ // every table referencing it. A parent is refreshed with --upsert.
470
+ await conn.query(`truncate only ${quoteIdent(table)}`);
471
+ } else if (existing > 0) {
472
+ log(`${table}: ${existing} rows already there, skipped (use --truncate or --upsert)`);
473
+ return { table, mode: 'skipped', rows: existing, seconds: 0, skipped: 'already has rows' };
474
+ }
475
+ let total = 0;
476
+ const started = Date.now();
477
+ const conns = [conn];
478
+ const sink = async (rows, idx) => {
479
+ const c = conns[idx] ?? (conns[idx] = await dst.connect().then(async (x) => (await session(x), x)));
480
+ const mapped = takesRowid ? rows.map((r) => ({ ...r, rowid: r.__rowid })) : rows;
481
+ await insertBatch(c, table, cols, mapped);
482
+ total += rows.length;
483
+ if (total % (BATCH * 10) < rows.length) log(`${table}: ${total} rows (${Math.round(total / Math.max(1, (Date.now() - started) / 1000))}/s)`);
484
+ };
485
+ try {
486
+ await streamRows(table, sink, { workers: WORKERS });
487
+ } finally {
488
+ for (const c of conns.slice(1)) c.release();
489
+ }
490
+ await resetSequences(conn, table, pgCols);
491
+ log(`${table}: ${total} rows`);
492
+ return { table, mode: opts.truncate ? 'truncate' : 'load', rows: total, seconds: 0 };
493
+ } finally {
494
+ conn.release();
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Refresh in place: everything into a temp table, then insert-or-update by
500
+ * primary key, delete what the source no longer has, reset sequences.
501
+ * @returns {Promise<TableReport>}
502
+ */
503
+ async function upsertTable(conn, table, cols, pgCols, takesRowid) {
504
+ const pk = await primaryKey(table);
505
+ if (!pk.length) throw new Error(`${table}: no primary key, cannot --upsert (use --truncate for a leaf table)`);
506
+ const tmp = `libsql_pg_tmp_${table}`.slice(0, 63);
507
+ const q = quoteIdent;
508
+ await conn.query('begin');
509
+ try {
510
+ if (replica) await conn.query("set local session_replication_role = 'replica'");
511
+ await conn.query(
512
+ `create temp table ${q(tmp)} (like ${q(table)} including defaults excluding identity excluding generated excluding indexes excluding constraints) on commit drop`,
513
+ );
514
+ const tmpCols = cols.map((c) => ({ ...c, is_identity: 'NO' }));
515
+ let total = 0;
516
+ await streamRows(table, async (rows) => {
517
+ const mapped = takesRowid ? rows.map((r) => ({ ...r, rowid: r.__rowid })) : rows;
518
+ await insertBatch(conn, tmp, tmpCols, mapped);
519
+ total += rows.length;
520
+ });
521
+ // Identity columns take their value on insert (OVERRIDING SYSTEM
522
+ // VALUE) but may not appear in the update branch.
523
+ const identity = new Set(pgCols.filter((c) => c.is_identity === 'YES').map((c) => c.column_name));
524
+ const nonPk = cols.map((c) => c.column_name).filter((c) => !pk.includes(c) && !identity.has(c));
525
+ const set = nonPk.length ? `do update set ${nonPk.map((c) => `${q(c)} = excluded.${q(c)}`).join(', ')}` : 'do nothing';
526
+ const list = cols.map((c) => q(c.column_name)).join(', ');
527
+ const res = await conn.query(
528
+ `insert into ${q(table)} (${list}) overriding system value select ${list} from ${q(tmp)} on conflict (${pk.map(q).join(', ')}) ${set}`,
529
+ );
530
+ // Mirror deletes: rows the source purged must not linger, or verify never matches.
531
+ const gone = await conn.query(
532
+ `delete from ${q(table)} t where not exists (select 1 from ${q(tmp)} s where (${pk.map((c) => `s.${q(c)}`).join(', ')}) = (${pk.map((c) => `t.${q(c)}`).join(', ')}))`,
533
+ );
534
+ await resetSequences(conn, table, pgCols);
535
+ await conn.query('commit');
536
+ log(`${table}: upserted ${res.rowCount} of ${total} rows, removed ${gone.rowCount} stale`);
537
+ return { table, mode: 'upsert', rows: total, seconds: 0 };
538
+ } catch (err) {
539
+ await conn.query('rollback').catch(() => {});
540
+ throw err;
541
+ }
542
+ }
543
+ } finally {
544
+ src.close();
545
+ await dst.end();
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Compare count(*) per table on both sides.
551
+ *
552
+ * @param {{ from: string, token?: string, to: string, tables?: string[], exclude?: string[], readTimeoutMs?: number, log?: (line: string) => void }} opts
553
+ * @returns {Promise<{ ok: boolean, rows: Array<{ table: string, source: number, target: number | null, ok: boolean }> }>}
554
+ */
555
+ export async function verifyCopy(opts) {
556
+ const log = opts.log ?? ((line) => console.log(line));
557
+ const libsql = await import('@libsql/client');
558
+ const TIMEOUT = opts.readTimeoutMs ?? 600_000;
559
+ const src = libsql.createClient({
560
+ url: opts.from,
561
+ authToken: opts.token,
562
+ intMode: 'number',
563
+ fetch: (input, init = {}) => fetch(input, { ...init, signal: AbortSignal.any([init.signal, AbortSignal.timeout(TIMEOUT)].filter(Boolean)) }),
564
+ });
565
+ const { connectionString, ssl } = connectionSettings(opts.to, undefined);
566
+ const dst = new pg.Pool({ connectionString, ssl, max: 2 });
567
+ const out = [];
568
+ try {
569
+ const master = (await src.execute("select name, type, sql from sqlite_master where type = 'table' order by name")).rows.map((r) => ({
570
+ name: String(r.name),
571
+ type: String(r.type),
572
+ sql: r.sql === null ? null : String(r.sql),
573
+ }));
574
+ let tables = userTables(master).map((t) => t.name);
575
+ if (opts.tables?.length) {
576
+ const want = new Set(opts.tables.map((t) => t.toLowerCase()));
577
+ tables = tables.filter((t) => want.has(t.toLowerCase()));
578
+ }
579
+ if (opts.exclude?.length) {
580
+ const skip = new Set(opts.exclude.map((t) => t.toLowerCase()));
581
+ tables = tables.filter((t) => !skip.has(t.toLowerCase()));
582
+ }
583
+ log(`${'table'.padEnd(32)} ${'source'.padStart(12)} ${'target'.padStart(12)}`);
584
+ for (const table of tables) {
585
+ const s = Number((await src.execute(`select count(*) as n from ${quoteIdent(table)}`)).rows[0].n);
586
+ let d = null;
587
+ try {
588
+ d = Number((await dst.query(`select count(*) as n from ${quoteIdent(table)}`)).rows[0].n);
589
+ } catch {
590
+ d = null;
591
+ }
592
+ const ok = d !== null && s === d;
593
+ out.push({ table, source: s, target: d, ok });
594
+ log(`${table.padEnd(32)} ${String(s).padStart(12)} ${String(d ?? 'missing').padStart(12)} ${ok ? 'ok' : 'DIFF'}`);
595
+ }
596
+ } finally {
597
+ src.close();
598
+ await dst.end();
599
+ }
600
+ const ok = out.every((r) => r.ok);
601
+ log(ok ? `verify: ${out.length} table(s) match` : `verify: ${out.filter((r) => !r.ok).length} of ${out.length} table(s) DIFFER`);
602
+ return { ok, rows: out };
603
+ }
604
+
605
+ /**
606
+ * Split a comma list from the command line.
607
+ * @param {string | undefined} value
608
+ */
609
+ export function commaList(value) {
610
+ return value ? value.split(',').map((s) => s.trim()).filter(Boolean) : [];
611
+ }
612
+
613
+ // Re-exported for tests of the FK ordering without a database.
614
+ export { splitTopLevel };