@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 +21 -0
- package/README.md +128 -0
- package/bin/libsql-pg.js +11 -0
- package/index.js +8 -0
- package/package.json +73 -0
- package/src/bind.js +141 -0
- package/src/cli.js +123 -0
- package/src/client.js +449 -0
- package/src/copy.js +614 -0
- package/src/errors.js +111 -0
- package/src/result.js +87 -0
- package/src/rewrite.js +549 -0
- package/src/schema.js +549 -0
- package/src/sqlparse.js +283 -0
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.
|
package/bin/libsql-pg.js
ADDED
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
|
+
}
|