@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @profullstack/libsql-pg
2
+
3
+ A drop-in replacement for `@libsql/client` that talks to Postgres, plus the two
4
+ tools that move a Turso/libSQL database across: a schema converter and a copier.
5
+
6
+ Porting an app off Turso becomes three steps:
7
+
8
+ ```sh
9
+ npx libsql-pg convert-schema db/schema.sql -o migrations-pg/0001_schema.sql # 1. DDL
10
+ psql "$DATABASE_URL" -f migrations-pg/0001_schema.sql
11
+ npx libsql-pg copy --from "$TURSO_DATABASE_URL" --token "$TURSO_AUTH_TOKEN" \
12
+ --to "$DATABASE_URL" --verify # 2. rows
13
+ # 3. in the app: import { createClient } from '@profullstack/libsql-pg'
14
+ ```
15
+
16
+ The pattern comes from rssamplifier.com's port (2026-09-25), where a libSQL
17
+ surface over `pg` let the app keep every query and change one import.
18
+
19
+ ## Client
20
+
21
+ ```js
22
+ import { createClient } from '@profullstack/libsql-pg';
23
+
24
+ const db = createClient({ url: process.env.DATABASE_URL }); // postgres://...
25
+ const rs = await db.execute({ sql: 'select * from feeds where id = ?', args: [id] });
26
+ rs.rows[0].title; rs.rows[0][1]; rs.rowsAffected; rs.lastInsertRowid;
27
+ await db.batch([{ sql: 'insert into a (x) values (?)', args: [1] }, 'delete from b'], 'write');
28
+ const tx = await db.transaction('write'); await tx.execute(...); await tx.commit();
29
+ ```
30
+
31
+ - The `@libsql/client` surface: `execute`, `batch`, `transaction`, `executeMultiple`,
32
+ `close`, `sync` (no-op), `protocol` = `'postgres'`. Positional `?` and named
33
+ `:name` / `@name` / `$name` arguments.
34
+ - `ResultSet` shape matches libsql: `columns`, `columnTypes`, `rows` (objects with
35
+ column keys AND numeric indexes, plus `length`), `rowsAffected`, and
36
+ `lastInsertRowid` as a BigInt for an INSERT into a table with an identity or
37
+ serial primary key (a `RETURNING` of that key is appended when the statement
38
+ has none; the key is looked up once per table and cached).
39
+ - Errors keep the messages SQLite code matches on: `UNIQUE constraint failed:
40
+ t.col`, `FOREIGN KEY constraint failed`, `NOT NULL constraint failed: t.col`;
41
+ the Postgres `code` is preserved.
42
+ - `dialect: 'sqlite'` (the default) rewrites each statement before binding, with
43
+ a cache keyed by SQL text. `dialect: 'postgres'` sends SQL as-is.
44
+ - `url` must be `postgres://` or `postgresql://`; `libsql://` and `file:` throw.
45
+ - Options: `pool: { max }`, `dialect`.
46
+
47
+ ## What the rewriter changes for you
48
+
49
+ | SQLite | Postgres |
50
+ | --- | --- |
51
+ | `INSERT OR IGNORE INTO t ...` | `INSERT INTO t ... ON CONFLICT DO NOTHING` |
52
+ | `INSERT OR REPLACE INTO t (cols) ...`, `REPLACE INTO` | `... ON CONFLICT (pk or unique cols) DO UPDATE SET col = EXCLUDED.col` (keys looked up in `pg_index`) |
53
+ | `datetime('now')`, `datetime('now','localtime')` | `now()` |
54
+ | `date('now')` | `current_date` |
55
+ | `strftime('%s','now')`, `unixepoch()` | `extract(epoch from now())::bigint` |
56
+ | `strftime(fmt, x)` (common formats) | `to_char(x at time zone 'utc', ...)` |
57
+ | `json_extract(col, '$.a.b[0]')` | `(col #>> '{a,b,0}')` |
58
+ | `lower(hex(randomblob(16)))`, `hex(randomblob(n))` | `encode(gen_random_bytes(n), 'hex')` |
59
+ | `group_concat(x, sep)` | `string_agg(x::text, sep)` |
60
+ | `ifnull(a, b)` | `coalesce(a, b)` |
61
+ | `CAST(x AS INTEGER)` | `CAST(x AS BIGINT)` (SQLite's integer is 64-bit) |
62
+ | `` `backticked` `` identifiers | `"quoted"` |
63
+ | `PRAGMA ...` | no-op, empty result |
64
+ | `CREATE TABLE`/`ALTER TABLE` inline | passed through the schema converter |
65
+ | `... MATCH ...` against an FTS5 table | throws, naming the table (see FTS below) |
66
+
67
+ `unsupportedIdioms(sql)` lists what a statement still needs by hand: `printf`,
68
+ `format`, `typeof`, `last_insert_rowid()`, `changes()`, `total_changes()`,
69
+ `COLLATE NOCASE` (use `citext` or `lower()`), `GLOB` (use `LIKE` or `~`),
70
+ `IS NOT <value>` (use `IS DISTINCT FROM`), `random()` (double in [0,1) in
71
+ Postgres, 64-bit integer in SQLite), and bare `rowid` (Postgres tables have no
72
+ implicit rowid; give the table an identity column named `rowid` or use the key).
73
+ Also by hand: `json_each`/`json_tree` (use `jsonb_array_elements`), `?NNN`
74
+ numbered parameters, integer booleans in comparisons (`= 1` against a boolean
75
+ column), and `LIMIT -1`.
76
+
77
+ ## `convert-schema`
78
+
79
+ ```sh
80
+ libsql-pg convert-schema schema.sqlite.sql [-o out.sql] [--json jsonb] [--search-column name] [--ts-config english]
81
+ ```
82
+
83
+ `INTEGER PRIMARY KEY [AUTOINCREMENT]` becomes `bigint generated by default as
84
+ identity primary key`; `INTEGER` -> `bigint`, `REAL` -> `double precision`,
85
+ `BLOB` -> `bytea`, `DATETIME`/`TIMESTAMP` -> `timestamptz`, `BOOLEAN` ->
86
+ `boolean`, `TEXT` stays; `DEFAULT (datetime('now'))`/`CURRENT_TIMESTAMP` ->
87
+ `default now()`, `DEFAULT (strftime('%s','now'))` -> epoch default; `WITHOUT
88
+ ROWID` dropped; indexes kept. `CREATE VIRTUAL TABLE x USING fts5(...)` becomes a
89
+ generated `tsvector` column plus a GIN index on the content table when the
90
+ converter can find it (and a commented TODO otherwise); triggers are emitted as
91
+ commented TODOs. Fixtures in `test/fixtures/`.
92
+
93
+ ## `copy` and `verify`
94
+
95
+ ```sh
96
+ libsql-pg copy --from libsql://... --token ... --to postgres://... \
97
+ [--tables a,b] [--exclude x,y] [--truncate] [--upsert] [--batch N] [--workers N] [--verify] [--dry-run]
98
+ libsql-pg verify --from ... --to ...
99
+ ```
100
+
101
+ Discovers tables from `sqlite_master`, orders them so foreign-key parents load
102
+ first, streams rows in batches (600 s read timeout with retries; min/max rowid
103
+ are two lookups, never one full scan), coerces SQLite integers into `boolean`
104
+ and `timestamptz` columns using the target's `information_schema`, resets
105
+ identity sequences with `setval(max)`, and `--verify` compares `count(*)` per
106
+ table. `--truncate` is `TRUNCATE ONLY` (a parent with children errors: that is
107
+ the point, use `--upsert`). `--upsert` refreshes in place by primary key and
108
+ mirrors deletes, so a second run before cutover closes the gap without a
109
+ rebuild.
110
+
111
+ ## Traps (all met in the rssamplifier port)
112
+
113
+ - `TRUNCATE ... CASCADE` empties the child tables too; the copier never uses it.
114
+ - An identity column must not appear in the `DO UPDATE SET` list.
115
+ - A fresh Postgres has no statistics: run `ANALYZE` after the load or the first
116
+ plans are terrible.
117
+ - Feed-style queries (`where id in (select ...) order by created_at limit n`)
118
+ want `with picked as materialized ... join lateral (...)`; the planner
119
+ otherwise scans the whole child table.
120
+ - Postgres rejects `sslmode=no-verify` in `pg` older than 8.x and Bun.SQL
121
+ forwards unknown URL parameters to the server; pass `ssl` options explicitly.
122
+
123
+ ## Development
124
+
125
+ Node >= 20, ESM, `pg` is the only runtime dependency (`@libsql/client` is an
126
+ optional peer for the copier's source side). `node --test` runs the unit tests
127
+ without a database; the integration tests skip unless `TEST_DATABASE_URL` is set
128
+ (CI runs them against `postgres:17-alpine`). MIT.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2))
5
+ .then((code) => {
6
+ process.exitCode = code;
7
+ })
8
+ .catch((err) => {
9
+ console.error(err?.stack ?? String(err));
10
+ process.exitCode = 1;
11
+ });
package/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { createClient, connectionSettings } from './src/client.js';
2
+ export { createRewriter, rewriteSql, rewriteStatement, rewriteFunctions, insertTarget, jsonPathToArray, unsupportedIdioms } from './src/rewrite.js';
3
+ export { convertSchema, convertStatement, convertDdl, mapType } from './src/schema.js';
4
+ export { positional, named, bind, prepare } from './src/bind.js';
5
+ export { translateError, ftsError } from './src/errors.js';
6
+ export { toResultSet, makeRow, emptyResultSet } from './src/result.js';
7
+ export { copyDatabase, verifyCopy, orderTables, referencedTables, userTables, coerce, insertSql } from './src/copy.js';
8
+ export { splitStatements, codeMask } from './src/sqlparse.js';
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@profullstack/libsql-pg",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Drop-in replacement for @libsql/client that talks to Postgres, plus the tools to move a Turso/libSQL database into it: the same execute/batch/transaction surface, SQLite idioms rewritten on the way through, a schema converter and a row copier.",
6
+ "keywords": [
7
+ "libsql",
8
+ "turso",
9
+ "sqlite",
10
+ "postgres",
11
+ "postgresql",
12
+ "migration",
13
+ "port",
14
+ "adapter",
15
+ "drop-in",
16
+ "pg",
17
+ "schema",
18
+ "copy"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/profullstack/libsql-pg.git"
23
+ },
24
+ "homepage": "https://github.com/profullstack/libsql-pg#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/profullstack/libsql-pg/issues"
27
+ },
28
+ "license": "MIT",
29
+ "author": "Profullstack, Inc.",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./index.d.ts",
33
+ "default": "./index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "types": "./index.d.ts",
38
+ "bin": {
39
+ "libsql-pg": "./bin/libsql-pg.js"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "bin",
44
+ "index.js",
45
+ "index.d.ts",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "test": "node --test \"test/*.test.js\"",
51
+ "test:unit": "node --test test/rewrite.test.js test/bind.test.js test/result.test.js test/errors.test.js test/schema.test.js test/copy-plan.test.js"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
59
+ "dependencies": {
60
+ "pg": "^8.13.0"
61
+ },
62
+ "peerDependencies": {
63
+ "@libsql/client": ">=0.5.0"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@libsql/client": {
67
+ "optional": true
68
+ }
69
+ },
70
+ "devDependencies": {
71
+ "@libsql/client": "^0.15.0"
72
+ }
73
+ }
package/src/bind.js ADDED
@@ -0,0 +1,141 @@
1
+ import { codeMask } from './sqlparse.js';
2
+
3
+ /**
4
+ * Placeholder translation and value binding.
5
+ *
6
+ * libSQL accepts `?`, `?NNN`, `:name`, `@name` and `$name`; Postgres wants
7
+ * `$1..$n`. Positional and named forms are handled here, outside literals and
8
+ * comments, and the args are lined up with the numbers that come out.
9
+ */
10
+
11
+ /**
12
+ * Turn `?` and `?NNN` placeholders into `$1..$n`.
13
+ *
14
+ * `?` counts up; `?3` becomes `$3` (libSQL numbers from 1, as Postgres does).
15
+ * A `?` that is a jsonb operator (`?`, `?|`, `?&`) cannot be told apart from
16
+ * a placeholder; use `jsonb_exists()` in Postgres-native SQL instead.
17
+ *
18
+ * @param {string} sql
19
+ * @returns {string}
20
+ */
21
+ export function positional(sql) {
22
+ const mask = codeMask(sql);
23
+ let out = '';
24
+ let n = 0;
25
+ let i = 0;
26
+ while (i < sql.length) {
27
+ if (mask[i] === '?') {
28
+ const num = /^\d+/.exec(mask.slice(i + 1, i + 8));
29
+ if (num) {
30
+ out += `$${num[0]}`;
31
+ i += 1 + num[0].length;
32
+ } else {
33
+ n += 1;
34
+ out += `$${n}`;
35
+ i += 1;
36
+ }
37
+ } else {
38
+ out += sql[i];
39
+ i += 1;
40
+ }
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Turn `:name`, `@name` and `$name` placeholders into `$1..$n` and line the
47
+ * named values up with them. A name used twice gets one number. A `::` cast
48
+ * and a `$1` that is already numeric are left alone.
49
+ *
50
+ * @param {string} sql
51
+ * @param {Record<string, unknown>} named keys with or without their prefix
52
+ * @returns {{ sql: string, values: unknown[] }}
53
+ */
54
+ export function named(sql, named) {
55
+ const lookup = new Map();
56
+ for (const [k, v] of Object.entries(named)) lookup.set(k.replace(/^[:@$]/, ''), v);
57
+ const mask = codeMask(sql);
58
+ const order = [];
59
+ const numbers = new Map();
60
+ let out = '';
61
+ let i = 0;
62
+ while (i < sql.length) {
63
+ const c = mask[i];
64
+ if ((c === ':' || c === '@' || c === '$') && /[A-Za-z_]/.test(mask[i + 1] ?? '')) {
65
+ const prev = i > 0 ? mask[i - 1] : '';
66
+ if (c === ':' && prev === ':') {
67
+ out += c;
68
+ i++;
69
+ continue;
70
+ }
71
+ // `::text` casts: the second colon is followed by a type name.
72
+ if (c === ':' && mask[i + 1] === ':') {
73
+ out += c;
74
+ i++;
75
+ continue;
76
+ }
77
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(mask.slice(i + 1, i + 128));
78
+ const name = m[0];
79
+ // A `:` right after an identifier character is not a placeholder
80
+ // (`a:b` never appears in SQL, but a cast `x::y` is handled above).
81
+ if (c === ':' && /[A-Za-z0-9_)]/.test(prev)) {
82
+ out += c;
83
+ i++;
84
+ continue;
85
+ }
86
+ if (!lookup.has(name)) {
87
+ throw new Error(`named argument "${name}" was not supplied (have: ${[...lookup.keys()].join(', ') || 'none'})`);
88
+ }
89
+ if (!numbers.has(name)) {
90
+ numbers.set(name, order.length + 1);
91
+ order.push(lookup.get(name));
92
+ }
93
+ out += `$${numbers.get(name)}`;
94
+ i += 1 + name.length;
95
+ } else {
96
+ out += sql[i];
97
+ i++;
98
+ }
99
+ }
100
+ return { sql: out, values: order };
101
+ }
102
+
103
+ /**
104
+ * libSQL binds JS values loosely; pg is stricter. Undefined is null (the
105
+ * local libSQL client does the same). Booleans go as 1/0: Postgres reads
106
+ * `'1'` into a boolean column and into a bigint flag column kept from SQLite
107
+ * alike, where `'true'` only fits the first (`nativeBooleans: true` sends
108
+ * true/false). BigInts go as decimal strings. Dates become ISO strings. Typed
109
+ * arrays become Buffers (bytea).
110
+ *
111
+ * @param {unknown[]} args
112
+ * @param {{ nativeBooleans?: boolean }} [opts]
113
+ */
114
+ export function bind(args, opts = {}) {
115
+ return args.map((v) => {
116
+ if (v === undefined) return null;
117
+ if (typeof v === 'boolean') return opts.nativeBooleans ? v : v ? 1 : 0;
118
+ if (typeof v === 'bigint') return v.toString();
119
+ if (v instanceof Date) return v.toISOString();
120
+ if (v instanceof ArrayBuffer) return Buffer.from(v);
121
+ if (ArrayBuffer.isView(v) && !(v instanceof Buffer)) return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
122
+ return v;
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Take a libSQL statement in any of its three shapes and produce the pg
128
+ * `{ text, values }` pair.
129
+ *
130
+ * @param {string} sql already rewritten SQL
131
+ * @param {unknown[] | Record<string, unknown> | undefined} args
132
+ * @param {{ nativeBooleans?: boolean }} [opts]
133
+ * @returns {{ text: string, values: unknown[] }}
134
+ */
135
+ export function prepare(sql, args, opts = {}) {
136
+ if (args && !Array.isArray(args) && typeof args === 'object') {
137
+ const r = named(sql, args);
138
+ return { text: r.sql, values: bind(r.values, opts) };
139
+ }
140
+ return { text: positional(sql), values: bind(args ?? [], opts) };
141
+ }
package/src/cli.js ADDED
@@ -0,0 +1,123 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { createRequire } from 'node:module';
3
+
4
+ import { commaList, copyDatabase, verifyCopy } from './copy.js';
5
+ import { convertSchema } from './schema.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const { version } = require('../package.json');
9
+
10
+ const USAGE = `libsql-pg ${version}
11
+
12
+ libsql-pg convert-schema <sqlite-schema.sql> [-o out.sql] [--json jsonb] [--search-column name] [--ts-config english]
13
+ Convert SQLite DDL to Postgres DDL (stdout unless -o).
14
+
15
+ libsql-pg copy --from <libsql://...|file:...> [--token ...] --to <postgres://...>
16
+ [--tables a,b] [--exclude x,y] [--truncate] [--upsert] [--batch N] [--workers N] [--verify] [--dry-run]
17
+ Copy every user table from the source into Postgres. Postgres must
18
+ already hold the schema. Default: skip a table that already has rows.
19
+ --truncate TRUNCATE ONLY each table first (a parent with children errors; use --upsert)
20
+ --upsert refresh in place by primary key, mirroring deletes
21
+ --verify after loading (or alone), compare count(*) per table; exit 1 on a difference
22
+ Env fallbacks: TURSO_DATABASE_URL / LIBSQL_URL, TURSO_AUTH_TOKEN, DATABASE_URL.
23
+
24
+ libsql-pg verify --from ... --to ... Only the count comparison.
25
+ `;
26
+
27
+ /** Flags that never take a value. */
28
+ const BOOLEAN_FLAGS = new Set(['truncate', 'upsert', 'verify', 'dry-run', 'help', 'version']);
29
+
30
+ /** @param {string[]} argv */
31
+ export function parseArgs(argv) {
32
+ const flags = {};
33
+ const positional = [];
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a.startsWith('--')) {
37
+ const [k, inline] = a.slice(2).split('=', 2);
38
+ if (inline !== undefined) flags[k] = inline;
39
+ else if (BOOLEAN_FLAGS.has(k)) flags[k] = true;
40
+ else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) flags[k] = argv[++i];
41
+ else flags[k] = true;
42
+ } else if (a === '-o') flags.out = argv[++i];
43
+ else if (a === '-h') flags.help = true;
44
+ else positional.push(a);
45
+ }
46
+ return { flags, positional };
47
+ }
48
+
49
+ /**
50
+ * @param {string[]} argv
51
+ * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void }} [io]
52
+ * @returns {Promise<number>} exit code
53
+ */
54
+ export async function main(argv, io = {}) {
55
+ const out = io.stdout ?? ((s) => process.stdout.write(s));
56
+ const err = io.stderr ?? ((s) => process.stderr.write(s));
57
+ const { flags, positional } = parseArgs(argv);
58
+ const command = positional[0];
59
+ if (flags.version) {
60
+ out(`${version}\n`);
61
+ return 0;
62
+ }
63
+ if (!command || flags.help) {
64
+ out(USAGE);
65
+ return command ? 0 : 1;
66
+ }
67
+
68
+ if (command === 'convert-schema') {
69
+ const file = positional[1];
70
+ if (!file) {
71
+ err('convert-schema: a SQLite schema file is required\n');
72
+ return 1;
73
+ }
74
+ const sql = await readFile(file, 'utf8');
75
+ const converted = convertSchema(sql, {
76
+ json: flags.json === 'jsonb' ? 'jsonb' : 'text',
77
+ searchColumn: typeof flags['search-column'] === 'string' ? flags['search-column'] : undefined,
78
+ textSearchConfig: typeof flags['ts-config'] === 'string' ? flags['ts-config'] : undefined,
79
+ });
80
+ if (typeof flags.out === 'string') {
81
+ await writeFile(flags.out, converted);
82
+ err(`wrote ${flags.out} (${converted.split('\n').filter((l) => /TODO/.test(l)).length} TODO line(s))\n`);
83
+ } else out(converted);
84
+ return 0;
85
+ }
86
+
87
+ if (command === 'copy' || command === 'verify') {
88
+ const env = process.env;
89
+ const from = typeof flags.from === 'string' ? flags.from : env.TURSO_DATABASE_URL ?? env.LIBSQL_URL;
90
+ const token = typeof flags.token === 'string' ? flags.token : env.TURSO_AUTH_TOKEN;
91
+ const to = typeof flags.to === 'string' ? flags.to : env.DATABASE_URL;
92
+ if (!from || !to) {
93
+ err(`${command}: --from and --to are required (or TURSO_DATABASE_URL and DATABASE_URL)\n`);
94
+ return 1;
95
+ }
96
+ const common = {
97
+ from,
98
+ token,
99
+ to,
100
+ tables: commaList(typeof flags.tables === 'string' ? flags.tables : undefined),
101
+ exclude: commaList(typeof flags.exclude === 'string' ? flags.exclude : undefined),
102
+ log: (line) => err(`${new Date().toISOString().slice(11, 19)} ${line}\n`),
103
+ };
104
+ if (command === 'copy') {
105
+ const reports = await copyDatabase({
106
+ ...common,
107
+ truncate: flags.truncate === true,
108
+ upsert: flags.upsert === true,
109
+ batch: typeof flags.batch === 'string' ? Number(flags.batch) : undefined,
110
+ workers: typeof flags.workers === 'string' ? Number(flags.workers) : undefined,
111
+ dryRun: flags['dry-run'] === true,
112
+ });
113
+ out(`\n${'table'.padEnd(32)} ${'mode'.padEnd(9)} ${'rows'.padStart(12)} ${'secs'.padStart(6)}\n`);
114
+ for (const r of reports) out(`${r.table.padEnd(32)} ${r.mode.padEnd(9)} ${String(r.rows).padStart(12)} ${String(r.seconds).padStart(6)}${r.skipped ? ` ${r.skipped}` : ''}\n`);
115
+ if (flags['dry-run'] === true || flags.verify !== true) return 0;
116
+ }
117
+ const v = await verifyCopy({ ...common, log: (line) => out(`${line}\n`) });
118
+ return v.ok ? 0 : 1;
119
+ }
120
+
121
+ err(`unknown command "${command}"\n\n${USAGE}`);
122
+ return 1;
123
+ }