@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/client.js ADDED
@@ -0,0 +1,449 @@
1
+ import pg from 'pg';
2
+
3
+ import { prepare } from './bind.js';
4
+ import { translateError } from './errors.js';
5
+ import { emptyResultSet, toResultSet } from './result.js';
6
+ import { createRewriter } from './rewrite.js';
7
+ import { convertDdl } from './schema.js';
8
+ import { codeMask, splitStatements } from './sqlparse.js';
9
+
10
+ /**
11
+ * Postgres behind the @libsql/client surface.
12
+ *
13
+ * `execute`, `batch`, `transaction`, `executeMultiple`, `close`, `sync` and
14
+ * the ResultSet shape are libSQL's; the SQL is rewritten from SQLite's
15
+ * dialect on the way through (see ./rewrite.js) and `?` / `:name`
16
+ * placeholders become `$n`. The approach is the one rssamplifier.com shipped
17
+ * on 2026-09-25, generalised.
18
+ *
19
+ * @typedef {'sqlite' | 'postgres'} Dialect
20
+ * @typedef {'write' | 'read' | 'deferred'} TransactionMode
21
+ * @typedef {{
22
+ * url: string,
23
+ * authToken?: string,
24
+ * syncUrl?: string,
25
+ * dialect?: Dialect,
26
+ * pool?: { max?: number, idleTimeoutMillis?: number, connectionTimeoutMillis?: number },
27
+ * intMode?: 'number' | 'bigint' | 'string',
28
+ * timestamps?: 'iso' | 'date',
29
+ * nativeBooleans?: boolean,
30
+ * statementTimeoutMs?: number,
31
+ * applicationName?: string,
32
+ * ssl?: boolean | object,
33
+ * onWarning?: (message: string, sql: string) => void,
34
+ * }} Config
35
+ */
36
+
37
+ const { Pool, types } = pg;
38
+
39
+ /**
40
+ * Read `sslmode=` out of the URL. pg lets the parameter override its `ssl`
41
+ * option and its `require` verifies the certificate, which a self-signed
42
+ * cert on your own box fails; libpq's `require` never verified. So the
43
+ * parameter is honoured here and removed from the string.
44
+ *
45
+ * @param {string} url
46
+ * @param {boolean | object | undefined} explicit
47
+ */
48
+ export function connectionSettings(url, explicit) {
49
+ const parsed = new URL(url);
50
+ const mode = parsed.searchParams.get('sslmode');
51
+ parsed.searchParams.delete('sslmode');
52
+ let ssl = explicit;
53
+ if (ssl === undefined) {
54
+ if (mode === 'require' || mode === 'prefer') ssl = { rejectUnauthorized: false };
55
+ else if (mode === 'verify-ca' || mode === 'verify-full') ssl = true;
56
+ else ssl = undefined;
57
+ }
58
+ return { connectionString: parsed.toString(), ssl };
59
+ }
60
+
61
+ /**
62
+ * @param {string} url
63
+ */
64
+ function assertPostgresUrl(url) {
65
+ if (typeof url !== 'string' || !url) throw new TypeError('createClient: `url` is required (postgres://user:pass@host:5432/db)');
66
+ const scheme = url.split(':')[0].toLowerCase();
67
+ if (scheme === 'postgres' || scheme === 'postgresql') return;
68
+ const hint =
69
+ scheme === 'libsql' || scheme === 'file' || scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss'
70
+ ? ' This client speaks Postgres only; move the data first with `libsql-pg copy --from <that url> --to postgres://...` and point `url` at Postgres.'
71
+ : '';
72
+ throw new Error(`createClient: url must be postgres:// or postgresql://, got "${scheme}:".${hint}`);
73
+ }
74
+
75
+ /**
76
+ * Per-client type parsing: int8 as number (libSQL's default), or bigint or
77
+ * string via `intMode`; timestamps as ISO text (what a SQLite app stored and
78
+ * compared) unless `timestamps: 'date'`.
79
+ *
80
+ * @param {Config} config
81
+ */
82
+ function typeParsers(config) {
83
+ const intMode = config.intMode ?? 'number';
84
+ const int8 =
85
+ intMode === 'bigint' ? (v) => (v === null ? null : BigInt(v)) : intMode === 'string' ? (v) => v : (v) => (v === null ? null : Number(v));
86
+ const numeric = intMode === 'string' ? (v) => v : (v) => (v === null ? null : Number(v));
87
+ const iso = (v) => {
88
+ if (v === null) return null;
89
+ const d = new Date(v.includes('+') || v.endsWith('Z') || /[+-]\d\d(:\d\d)?$/.test(v) ? v : `${v}Z`);
90
+ return Number.isNaN(d.getTime()) ? v : d.toISOString();
91
+ };
92
+ const overrides = new Map([
93
+ [20, int8],
94
+ [1700, numeric],
95
+ ]);
96
+ if ((config.timestamps ?? 'iso') === 'iso') {
97
+ overrides.set(1184, iso);
98
+ overrides.set(1114, iso);
99
+ overrides.set(1082, (v) => v); // date: keep 'YYYY-MM-DD'
100
+ }
101
+ return {
102
+ getTypeParser(oid, format) {
103
+ if (format !== 'binary' && overrides.has(oid)) return overrides.get(oid);
104
+ return types.getTypeParser(oid, format);
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * @param {string} sql
111
+ * @returns {string | null} the INSERT's target table, lower-cased, when the
112
+ * statement is a plain INSERT without RETURNING
113
+ */
114
+ function insertWithoutReturning(sql) {
115
+ const mask = codeMask(sql);
116
+ const m = /^\s*insert\s+into\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_.]*)/i.exec(mask);
117
+ if (!m) return null;
118
+ if (/\breturning\b/i.test(mask)) return null;
119
+ const raw = sql.slice(m.index + m[0].length - m[1].length, m.index + m[0].length);
120
+ return raw.startsWith('"') ? raw.slice(1, -1) : raw.toLowerCase();
121
+ }
122
+
123
+ /**
124
+ * @param {Config} config
125
+ */
126
+ export function createClient(config) {
127
+ if (!config || typeof config !== 'object') throw new TypeError('createClient(config): config object required');
128
+ assertPostgresUrl(config.url);
129
+ const dialect = config.dialect ?? 'sqlite';
130
+ if (dialect !== 'sqlite' && dialect !== 'postgres') throw new RangeError(`dialect must be 'sqlite' or 'postgres', got ${dialect}`);
131
+ const { connectionString, ssl } = connectionSettings(config.url, config.ssl);
132
+ const pool = new Pool({
133
+ connectionString,
134
+ ssl,
135
+ max: config.pool?.max ?? 10,
136
+ idleTimeoutMillis: config.pool?.idleTimeoutMillis,
137
+ connectionTimeoutMillis: config.pool?.connectionTimeoutMillis,
138
+ application_name: config.applicationName ?? 'libsql-pg',
139
+ statement_timeout: config.statementTimeoutMs,
140
+ allowExitOnIdle: true,
141
+ types: typeParsers(config),
142
+ });
143
+ pool.on('error', () => {
144
+ /* an idle connection dropped by the server; the next query reconnects */
145
+ });
146
+ const bindOpts = { nativeBooleans: config.nativeBooleans ?? false };
147
+
148
+ /** @type {Map<string, import('./rewrite.js').TableKeys | undefined>} */
149
+ const keysCache = new Map();
150
+ /** @type {Map<string, string | null>} table -> identity/serial pk column */
151
+ const pkCache = new Map();
152
+ /** @type {Map<string, string[]>} constraint -> columns */
153
+ const constraintCache = new Map();
154
+ const warned = new Set();
155
+ let pgcryptoEnsured = false;
156
+
157
+ const rewriter = createRewriter({
158
+ keys: (table) => keysCache.get(table),
159
+ ddl: (sql) => convertDdl(sql),
160
+ });
161
+
162
+ /** Primary key, unique indexes and columns of a table, cached. */
163
+ async function loadKeys(table) {
164
+ if (keysCache.has(table)) return keysCache.get(table);
165
+ let info;
166
+ try {
167
+ const { rows } = await pool.query(
168
+ `select i.indisprimary as pk, array_agg(a.attname::text order by x.ord) as cols
169
+ from pg_index i
170
+ join lateral unnest(i.indkey) with ordinality as x(attnum, ord) on true
171
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = x.attnum
172
+ where i.indrelid = to_regclass($1) and (i.indisprimary or i.indisunique) and i.indpred is null
173
+ group by i.indexrelid, i.indisprimary
174
+ order by i.indisprimary desc, min(x.ord)`,
175
+ [table.includes('"') || /^[a-z_][a-z0-9_.]*$/.test(table) ? table : `"${table}"`],
176
+ );
177
+ const cols = await pool.query(
178
+ `select attname from pg_attribute where attrelid = to_regclass($1) and attnum > 0 and not attisdropped order by attnum`,
179
+ [table.includes('"') || /^[a-z_][a-z0-9_.]*$/.test(table) ? table : `"${table}"`],
180
+ );
181
+ const pk = rows.find((r) => r.pk)?.cols ?? [];
182
+ const unique = rows.filter((r) => !r.pk).map((r) => r.cols);
183
+ info = { pk, unique, columns: cols.rows.map((r) => r.attname) };
184
+ } catch {
185
+ info = undefined;
186
+ }
187
+ keysCache.set(table, info);
188
+ return info;
189
+ }
190
+
191
+ /** The identity or serial primary-key column of a table, if it has one. */
192
+ async function loadPk(table) {
193
+ if (pkCache.has(table)) return pkCache.get(table);
194
+ let col = null;
195
+ try {
196
+ const { rows } = await pool.query(
197
+ `select a.attname
198
+ from pg_index i
199
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
200
+ left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
201
+ where i.indrelid = to_regclass($1) and i.indisprimary and array_length(i.indkey, 1) = 1
202
+ and (a.attidentity <> '' or pg_get_expr(d.adbin, d.adrelid) like 'nextval(%')`,
203
+ [/^[a-z_][a-z0-9_.]*$/.test(table) ? table : `"${table}"`],
204
+ );
205
+ col = rows[0]?.attname ?? null;
206
+ } catch {
207
+ col = null;
208
+ }
209
+ pkCache.set(table, col);
210
+ return col;
211
+ }
212
+
213
+ /** Columns of a unique constraint or index, for the SQLite-style message. */
214
+ async function constraintColumns(name) {
215
+ if (!name) return undefined;
216
+ if (constraintCache.has(name)) return constraintCache.get(name);
217
+ let cols;
218
+ try {
219
+ const { rows } = await pool.query(
220
+ `select a.attname
221
+ from pg_class c
222
+ join pg_index i on i.indexrelid = c.oid
223
+ join lateral unnest(i.indkey) with ordinality as x(attnum, ord) on true
224
+ join pg_attribute a on a.attrelid = i.indrelid and a.attnum = x.attnum
225
+ where c.relname = $1
226
+ order by x.ord`,
227
+ [name],
228
+ );
229
+ cols = rows.map((r) => r.attname);
230
+ } catch {
231
+ cols = undefined;
232
+ }
233
+ constraintCache.set(name, cols);
234
+ return cols;
235
+ }
236
+
237
+ async function translate(err) {
238
+ if (err?.code === '23505') {
239
+ const columns = await constraintColumns(err.constraint);
240
+ return translateError(err, { columns });
241
+ }
242
+ return translateError(err);
243
+ }
244
+
245
+ /**
246
+ * @param {string | { sql: string, args?: unknown[] | Record<string, unknown> }} statement
247
+ * @param {unknown[] | Record<string, unknown>} [args]
248
+ */
249
+ function normalize(statement, args) {
250
+ if (typeof statement === 'string') return { sql: statement, args };
251
+ if (statement && typeof statement === 'object' && typeof statement.sql === 'string') return { sql: statement.sql, args: statement.args };
252
+ throw new TypeError('execute() takes a SQL string or { sql, args }');
253
+ }
254
+
255
+ /**
256
+ * Rewrite (resolving any table keys the rewrite needs) and bind.
257
+ * @param {{ sql: string, args?: unknown[] | Record<string, unknown> }} st
258
+ */
259
+ async function compile(st) {
260
+ let rewritten;
261
+ if (dialect === 'sqlite') {
262
+ const need = rewriter.needsKeys(st.sql);
263
+ if (need) await loadKeys(need);
264
+ rewritten = rewriter.rewrite(st.sql);
265
+ if (config.onWarning && rewritten.warnings.length && !warned.has(st.sql)) {
266
+ warned.add(st.sql);
267
+ for (const w of rewritten.warnings) config.onWarning(w, st.sql);
268
+ }
269
+ } else {
270
+ rewritten = { sql: st.sql, noop: false, warnings: [], kind: 'dml' };
271
+ }
272
+ if (rewritten.noop) return { noop: true };
273
+ let text = rewritten.sql;
274
+ // randomblob() became gen_random_bytes(), which lives in pgcrypto. Ask
275
+ // for the extension once; if the role may not, the real error follows.
276
+ if (!pgcryptoEnsured && /\bgen_random_bytes\s*\(/i.test(text)) {
277
+ pgcryptoEnsured = true;
278
+ await pool.query('create extension if not exists pgcrypto').catch(() => {});
279
+ }
280
+ // lastInsertRowid: ask for the identity/serial primary key when the
281
+ // statement is an INSERT without its own RETURNING.
282
+ let rowidColumn = null;
283
+ const table = insertWithoutReturning(text);
284
+ if (table) {
285
+ rowidColumn = await loadPk(table);
286
+ if (rowidColumn) text = `${text.replace(/;\s*$/, '')} RETURNING "${rowidColumn}"`;
287
+ }
288
+ const prepared = prepare(text, st.args, bindOpts);
289
+ return { noop: false, text: prepared.text, values: prepared.values, rowidColumn };
290
+ }
291
+
292
+ /**
293
+ * Run one statement on a pool or a checked-out connection.
294
+ * @param {{ query: Function }} target
295
+ * @param {{ sql: string, args?: unknown[] | Record<string, unknown> }} st
296
+ */
297
+ async function run(target, st) {
298
+ const c = await compile(st);
299
+ if (c.noop) return emptyResultSet();
300
+ let res;
301
+ try {
302
+ res = await target.query({ text: c.text, values: c.values, rowMode: 'array' });
303
+ } catch (err) {
304
+ throw await translate(err);
305
+ }
306
+ if (c.rowidColumn) {
307
+ const value = res.rows?.[0]?.[0];
308
+ const lastInsertRowid = value === undefined || value === null ? undefined : BigInt(value);
309
+ return toResultSet(res, { lastInsertRowid, rowsAffected: res.rowCount ?? 0, hideRows: true });
310
+ }
311
+ return toResultSet(res);
312
+ }
313
+
314
+ function beginSql(mode) {
315
+ if (mode === 'read') return 'begin read only';
316
+ if (mode === 'write' || mode === 'deferred' || mode === undefined) return 'begin';
317
+ throw new RangeError('Unknown transaction mode, supported values are "write", "read" and "deferred"');
318
+ }
319
+
320
+ /** Statements a pooled client cannot honour: each execute may land on another connection. */
321
+ function rejectBareTransactionControl(sql) {
322
+ if (/^\s*(begin|commit|rollback|end)\b/i.test(codeMask(sql))) {
323
+ throw new Error(`"${sql.trim().split(/\s+/)[0].toUpperCase()}" through execute() runs on one pooled connection and the next statement on another; use client.transaction() or client.batch() instead`);
324
+ }
325
+ }
326
+
327
+ const client = {
328
+ /** @type {'postgres'} */
329
+ protocol: 'postgres',
330
+ closed: false,
331
+
332
+ async execute(statement, args) {
333
+ const st = normalize(statement, args);
334
+ rejectBareTransactionControl(st.sql);
335
+ return run(pool, st);
336
+ },
337
+
338
+ /**
339
+ * One transaction on one connection, one result per statement, in order.
340
+ * @param {Array<string | { sql: string, args?: unknown[] | Record<string, unknown> }>} statements
341
+ * @param {TransactionMode} [mode]
342
+ */
343
+ async batch(statements, mode = 'deferred') {
344
+ const begin = beginSql(mode);
345
+ const conn = await pool.connect();
346
+ try {
347
+ await conn.query(begin);
348
+ const results = [];
349
+ for (const s of statements) results.push(await run(conn, normalize(s)));
350
+ await conn.query('commit');
351
+ return results;
352
+ } catch (err) {
353
+ try {
354
+ await conn.query('rollback');
355
+ } catch {
356
+ /* connection already gone */
357
+ }
358
+ throw err;
359
+ } finally {
360
+ conn.release();
361
+ }
362
+ },
363
+
364
+ /**
365
+ * An interactive transaction held across awaits; libSQL's Transaction surface.
366
+ * @param {TransactionMode} [mode]
367
+ */
368
+ async transaction(mode = 'deferred') {
369
+ const begin = beginSql(mode);
370
+ const conn = await pool.connect();
371
+ let open = true;
372
+ try {
373
+ await conn.query(begin);
374
+ } catch (err) {
375
+ conn.release();
376
+ throw await translate(err);
377
+ }
378
+ const finish = async (verb) => {
379
+ if (!open) return;
380
+ open = false;
381
+ try {
382
+ await conn.query(verb);
383
+ } catch (err) {
384
+ throw await translate(err);
385
+ } finally {
386
+ conn.release();
387
+ }
388
+ };
389
+ return {
390
+ execute: (statement, args) => run(conn, normalize(statement, args)),
391
+ async batch(statements) {
392
+ const out = [];
393
+ for (const s of statements) out.push(await run(conn, normalize(s)));
394
+ return out;
395
+ },
396
+ async executeMultiple(sql) {
397
+ for (const s of splitStatements(sql)) await run(conn, { sql: s });
398
+ },
399
+ commit: () => finish('commit'),
400
+ rollback: () => finish('rollback'),
401
+ close: () => finish('rollback'),
402
+ get closed() {
403
+ return !open;
404
+ },
405
+ };
406
+ },
407
+
408
+ /**
409
+ * Several statements in one string, run in order on one connection with
410
+ * no transaction around them (libSQL's behaviour). PRAGMAs are skipped
411
+ * and DDL goes through the schema converter.
412
+ * @param {string} sql
413
+ */
414
+ async executeMultiple(sql) {
415
+ const conn = await pool.connect();
416
+ try {
417
+ for (const s of splitStatements(sql)) {
418
+ rejectBareTransactionControl(s);
419
+ await run(conn, { sql: s });
420
+ }
421
+ } finally {
422
+ conn.release();
423
+ }
424
+ },
425
+
426
+ /** Embedded-replica sync has no meaning here. */
427
+ async sync() {},
428
+
429
+ close() {
430
+ if (client.closed) return Promise.resolve();
431
+ client.closed = true;
432
+ return pool.end();
433
+ },
434
+
435
+ /**
436
+ * Rewrite a statement without running it: what Postgres will be sent.
437
+ * @param {string} sql
438
+ */
439
+ async explainRewrite(sql) {
440
+ const need = rewriter.needsKeys(sql);
441
+ if (need) await loadKeys(need);
442
+ return rewriter.rewrite(sql);
443
+ },
444
+
445
+ /** The pg pool, for COPY, LISTEN/NOTIFY and anything else libSQL never had. */
446
+ pool,
447
+ };
448
+ return client;
449
+ }