@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/schema.js ADDED
@@ -0,0 +1,549 @@
1
+ import { rewriteFunctions, rewriteStatement } from './rewrite.js';
2
+ import { codeMask, matchParen, quoteIdent, replaceCode, splitStatements, splitTopLevel, unquote } from './sqlparse.js';
3
+
4
+ /**
5
+ * SQLite DDL to Postgres DDL.
6
+ *
7
+ * The converter is a set of textual rules over a shallow parse of each
8
+ * CREATE TABLE: column definitions and table constraints are split at the
9
+ * top-level commas, types are mapped, the SQLite-only words are dropped and
10
+ * the DEFAULT expressions go through the same function rewriter the client
11
+ * uses at run time. Anything it does not understand is passed through
12
+ * unchanged, or emitted as a commented TODO when Postgres would refuse it
13
+ * (FTS5 virtual tables, triggers).
14
+ *
15
+ * @typedef {{ name: string, columns: Map<string, string> }} TableInfo
16
+ * @typedef {{ tables: Map<string, TableInfo>, usesPgcrypto: boolean, notes: string[] }} ConvertContext
17
+ * @typedef {{
18
+ * promoteTextTimestamps?: boolean,
19
+ * json?: 'text' | 'jsonb',
20
+ * searchColumn?: string,
21
+ * textSearchConfig?: string,
22
+ * }} ConvertOptions
23
+ */
24
+
25
+ const CONSTRAINT_START = /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i;
26
+ const COLUMN_CONSTRAINT_WORDS = /\b(constraint|not\s+null|null|primary\s+key|unique|check|default|collate|references|generated|as)\b/i;
27
+
28
+ /**
29
+ * Map a SQLite declared type (or none) to a Postgres type.
30
+ *
31
+ * SQLite's type affinity rules are followed loosely: anything with INT in it
32
+ * is an integer, CHAR/CLOB/TEXT are text, BLOB is bytea, REAL/FLOA/DOUB are
33
+ * doubles. Names SQLite ignores but people write (BOOLEAN, DATETIME, DATE,
34
+ * UUID) become the Postgres type they meant.
35
+ *
36
+ * @param {string} declared
37
+ * @param {ConvertOptions} [opts]
38
+ * @returns {string}
39
+ */
40
+ export function mapType(declared, opts = {}) {
41
+ const t = declared.trim().replace(/\s+/g, ' ').toUpperCase();
42
+ if (!t) return 'text';
43
+ // Postgres types already, so converting converted DDL is a no-op (the
44
+ // client runs run-time DDL through here, and so does a second pass).
45
+ if (/^(BYTEA|TSVECTOR|JSONB|BIGSERIAL|SERIAL|SMALLSERIAL|DOUBLE PRECISION|TIMESTAMPTZ|TIMESTAMP(TZ)? WITH(OUT)? TIME ZONE|CHARACTER VARYING(\(\d+\))?|INET|CIDR|MACADDR|INTERVAL|MONEY|POINT|XML|BIT(\(\d+\))?|VARBIT|BOX|LINE|PATH|POLYGON|CIRCLE|TSQUERY|OID|NAME|REGCLASS)$/.test(t)) {
46
+ return t.toLowerCase();
47
+ }
48
+ if (/\[\]$/.test(t)) return t.toLowerCase(); // an array type
49
+ if (/^BOOL/.test(t)) return 'boolean';
50
+ if (/^(DATETIME|TIMESTAMP)/.test(t)) return 'timestamptz';
51
+ if (/^DATE$/.test(t)) return 'date';
52
+ if (/^TIME$/.test(t)) return 'time';
53
+ if (/^UUID$/.test(t)) return 'uuid';
54
+ if (/^JSON/.test(t)) return opts.json === 'jsonb' ? 'jsonb' : 'text';
55
+ if (/INT/.test(t)) return 'bigint';
56
+ if (/CHAR|CLOB|TEXT|STRING/.test(t)) return 'text';
57
+ if (/BLOB|BINARY/.test(t)) return 'bytea';
58
+ if (/^(NUMERIC|DECIMAL)/.test(t)) {
59
+ const m = /\(([^)]*)\)/.exec(t);
60
+ return m ? `numeric(${m[1].trim()})` : 'numeric';
61
+ }
62
+ if (/REAL|FLOA|DOUB/.test(t)) return 'double precision';
63
+ return 'text';
64
+ }
65
+
66
+ /**
67
+ * Split a column definition into name, declared type and the constraint tail.
68
+ * @param {string} def
69
+ */
70
+ export function parseColumnDef(def) {
71
+ const text = def.trim();
72
+ const mask = codeMask(text);
73
+ // Name: quoted or bare.
74
+ let i = 0;
75
+ if (mask[0] === '"' || mask[0] === '`' || mask[0] === '[') {
76
+ const end = mask.indexOf(mask[0] === '[' ? ']' : mask[0], 1);
77
+ i = end === -1 ? text.length : end + 1;
78
+ } else {
79
+ const m = /^[^\s(]+/.exec(text);
80
+ i = m ? m[0].length : text.length;
81
+ }
82
+ const name = unquote(text.slice(0, i));
83
+ const rest = text.slice(i);
84
+ const restMask = codeMask(rest);
85
+ // Type: everything up to the first constraint keyword (a type may carry a
86
+ // parenthesised length and several words, e.g. `UNSIGNED BIG INT`).
87
+ const kw = COLUMN_CONSTRAINT_WORDS.exec(restMask);
88
+ const typeEnd = kw ? kw.index : rest.length;
89
+ const declared = rest.slice(0, typeEnd).trim();
90
+ const constraints = rest.slice(typeEnd).trim();
91
+ return { name, declared, constraints };
92
+ }
93
+
94
+ /**
95
+ * Rewrite the constraint tail of one column.
96
+ *
97
+ * @param {string} constraints
98
+ * @param {{ type: string, ctx: ConvertContext, opts: ConvertOptions }} info
99
+ * @returns {{ constraints: string, type: string, identity: boolean, notes: string[] }}
100
+ */
101
+ function convertColumnConstraints(constraints, { type, ctx, opts }) {
102
+ let c = ` ${constraints} `;
103
+ const notes = [];
104
+ let identity = false;
105
+
106
+ // Column-level ON CONFLICT clauses have no Postgres form.
107
+ c = replaceCode(c, /\son\s+conflict\s+(rollback|abort|fail|ignore|replace)\b/gi, (m) => {
108
+ notes.push(`dropped column conflict clause "${m.trim()}"`);
109
+ return ' ';
110
+ });
111
+ c = replaceCode(c, /\sautoincrement\b/gi, ' ');
112
+ c = replaceCode(c, /\sprimary\s+key(\s+(asc|desc))?\b/gi, ' PRIMARY KEY ');
113
+ // COLLATE NOCASE and friends.
114
+ c = replaceCode(c, /\scollate\s+(nocase|binary|rtrim|[A-Za-z_]+)\b/gi, (_m, name) => {
115
+ if (/^(nocase|binary|rtrim)$/i.test(name)) notes.push(`dropped COLLATE ${name.toUpperCase()}`);
116
+ else return ` COLLATE ${name}`;
117
+ return ' ';
118
+ });
119
+
120
+ const alreadyIdentity = /\bgenerated\s+(always|by\s+default)\s+as\s+identity\b/i.test(codeMask(c));
121
+ if (/\sPRIMARY KEY\s/.test(c) && type === 'bigint' && !alreadyIdentity) {
122
+ identity = true;
123
+ c = c.replace(/\sPRIMARY KEY\s/, ' ');
124
+ }
125
+
126
+ // DEFAULT expression.
127
+ c = rewriteDefault(c, type, ctx, notes);
128
+
129
+ // Generated columns must be STORED in Postgres.
130
+ c = replaceCode(c, /\b(generated\s+always\s+)?as\s*\(/gi, 'GENERATED ALWAYS AS (');
131
+ if (/GENERATED ALWAYS AS \(/.test(c)) {
132
+ const mask = codeMask(c);
133
+ const open = mask.indexOf('(', mask.indexOf('GENERATED ALWAYS AS'));
134
+ const close = matchParen(mask, open);
135
+ if (close !== -1) {
136
+ const after = c.slice(close + 1).replace(/^\s*(virtual|stored)\b/i, ' STORED');
137
+ c = `${c.slice(0, close + 1)}${/^\s*STORED/.test(after) ? after : ` STORED${after}`}`;
138
+ }
139
+ }
140
+
141
+ // A `check (flag in (0, 1))` on a column that became boolean is now wrong.
142
+ if (type === 'boolean') {
143
+ c = replaceCode(c, /\scheck\s*\(\s*[A-Za-z_"][^()]*\s+in\s*\(\s*[01]\s*,\s*[01]\s*\)\s*\)/gi, ' ');
144
+ }
145
+
146
+ if (identity) {
147
+ // Identity columns take no DEFAULT.
148
+ c = stripDefault(c);
149
+ c = ` GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY${c}`;
150
+ }
151
+ return { constraints: c.replace(/\s+/g, ' ').trim(), type, identity, notes };
152
+ }
153
+
154
+ /** Remove a DEFAULT clause (a literal or a parenthesised expression). */
155
+ function stripDefault(c) {
156
+ const mask = codeMask(c);
157
+ const m = /(?<!\bby)\sdefault\s+/i.exec(mask);
158
+ if (!m) return c;
159
+ let end = m.index + m[0].length;
160
+ if (mask[end] === '(') end = matchParen(mask, end) + 1;
161
+ else {
162
+ const rest = /^('[^']*(?:''[^']*)*'|[^\s]+)/.exec(c.slice(end));
163
+ // A literal string may contain spaces; the mask blanked it, so measure on the text.
164
+ end += rest ? rest[0].length : 0;
165
+ }
166
+ return `${c.slice(0, m.index)} ${c.slice(end)}`;
167
+ }
168
+
169
+ /**
170
+ * Rewrite the DEFAULT clause of a column's constraint tail in place.
171
+ * @param {string} c
172
+ * @param {string} type mapped Postgres type
173
+ * @param {ConvertContext} ctx
174
+ * @param {string[]} notes
175
+ */
176
+ function rewriteDefault(c, type, ctx, notes) {
177
+ const mask = codeMask(c);
178
+ const m = /(?<!\bby)\sdefault\s+/i.exec(mask);
179
+ if (!m) return c;
180
+ const start = m.index + m[0].length;
181
+ let end;
182
+ if (mask[start] === '(') end = matchParen(mask, start) + 1;
183
+ else {
184
+ const rest = /^('[^']*(?:''[^']*)*'|[^\s]+)/.exec(c.slice(start));
185
+ end = start + (rest ? rest[0].length : 0);
186
+ }
187
+ let expr = c.slice(start, end).trim();
188
+ const bare = /^\((.*)\)$/s.test(expr) ? expr.slice(1, -1).trim() : expr;
189
+ let out = bare;
190
+ const lower = bare.toLowerCase().replace(/\s+/g, '');
191
+ if (type === 'boolean' && /^[01]$/.test(bare)) out = bare === '1' ? 'true' : 'false';
192
+ else if (lower === 'current_timestamp' || /^datetime\('now'/.test(lower)) out = 'now()';
193
+ else if (/^strftime\('%y-%m-%dt%h:%m:%[fs]z?','now'\)$/.test(lower)) out = 'now()';
194
+ else out = rewriteFunctions(bare);
195
+ if (/gen_random_bytes/.test(out)) ctx.usesPgcrypto = true;
196
+ // to_char(...) as a default on a text column is fine; on timestamptz it is not.
197
+ if (type === 'timestamptz' && /^to_char\(/.test(out)) out = 'now()';
198
+ if (/^[A-Za-z_]/.test(out) && !/^(true|false|null|now\(\)|current_date|current_time|current_timestamp)$/i.test(out) && !/^\(/.test(out)) {
199
+ out = `(${out})`;
200
+ }
201
+ if (out !== bare) notes.push(`default ${expr} -> ${out}`);
202
+ return `${c.slice(0, start)}${out}${c.slice(end)}`;
203
+ }
204
+
205
+ /** Is the DEFAULT a "current time" expression (used to promote text columns)? */
206
+ function isNowDefault(constraints) {
207
+ const lower = constraints.toLowerCase().replace(/\s+/g, '');
208
+ return /default\(?(current_timestamp|datetime\('now'|strftime\('%y-%m-%dt%h:%m:%[fs]z?','now'\))/.test(lower);
209
+ }
210
+
211
+ /**
212
+ * Convert a table-level constraint.
213
+ * @param {string} def
214
+ * @param {string[]} notes
215
+ */
216
+ function convertTableConstraint(def, notes) {
217
+ let c = def;
218
+ c = replaceCode(c, /\sautoincrement\b/gi, '');
219
+ c = replaceCode(c, /\s+collate\s+(nocase|binary|rtrim)\b/gi, (_m, name) => {
220
+ notes.push(`dropped COLLATE ${name.toUpperCase()} in a table constraint`);
221
+ return '';
222
+ });
223
+ c = replaceCode(c, /\son\s+conflict\s+(rollback|abort|fail|ignore|replace)\b/gi, (m) => {
224
+ notes.push(`dropped conflict clause "${m.trim()}"`);
225
+ return '';
226
+ });
227
+ c = replaceCode(c, /\b(asc|desc)\b(?=\s*[,)])/gi, '');
228
+ return c.replace(/\s+/g, ' ').trim();
229
+ }
230
+
231
+ /**
232
+ * Convert one CREATE TABLE statement.
233
+ *
234
+ * @param {string} sql
235
+ * @param {ConvertContext} ctx
236
+ * @param {ConvertOptions} opts
237
+ * @returns {string}
238
+ */
239
+ export function convertCreateTable(sql, ctx, opts) {
240
+ const mask = codeMask(sql);
241
+ const head = /^\s*create\s+(temp(?:orary)?\s+)?table\s+(if\s+not\s+exists\s+)?/i.exec(mask);
242
+ if (!head) return sql;
243
+ let i = head[0].length;
244
+ // Table name.
245
+ let j = i;
246
+ while (j < mask.length) {
247
+ const c = mask[j];
248
+ if (c === '"' || c === '`' || c === '[') {
249
+ const end = mask.indexOf(c === '[' ? ']' : c, j + 1);
250
+ j = end === -1 ? mask.length : end + 1;
251
+ } else if (/[A-Za-z0-9_.$]/.test(c)) j++;
252
+ else break;
253
+ }
254
+ const rawName = sql.slice(i, j);
255
+ const name = rawName.split('.').map(unquote).join('.');
256
+ const nameSql = rawName
257
+ .split('.')
258
+ .map((p) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(p) ? p.toLowerCase() === p ? p : quoteIdent(p) : quoteIdent(unquote(p))))
259
+ .join('.');
260
+ const open = mask.indexOf('(', j);
261
+ if (open === -1 || /^\s*as\b/i.test(mask.slice(j))) {
262
+ // CREATE TABLE ... AS SELECT: pass the select through the DML rewriter.
263
+ return rewriteFunctions(sql);
264
+ }
265
+ const close = matchParen(mask, open);
266
+ if (close === -1) return sql;
267
+ const body = sql.slice(open + 1, close);
268
+ const tail = sql.slice(close + 1);
269
+ const defs = splitTopLevel(body);
270
+ const notes = [];
271
+ const columns = new Map();
272
+ const out = [];
273
+ const parsed = [];
274
+ for (const def of defs) {
275
+ if (CONSTRAINT_START.test(codeMask(def).trim())) {
276
+ parsed.push({ constraint: def });
277
+ continue;
278
+ }
279
+ const col = parseColumnDef(def);
280
+ let type = mapType(col.declared, opts);
281
+ if (type === 'text' && opts.promoteTextTimestamps !== false && isNowDefault(col.constraints)) {
282
+ type = 'timestamptz';
283
+ notes.push(`${col.name}: text column with a current-time default became timestamptz`);
284
+ }
285
+ if (col.declared && mapType(col.declared, opts) === 'text' && !/CHAR|CLOB|TEXT|STRING|JSON/i.test(col.declared)) {
286
+ notes.push(`${col.name}: unknown type "${col.declared}" mapped to text`);
287
+ }
288
+ parsed.push({ column: col, type });
289
+ }
290
+ // A single-column table-level PRIMARY KEY on an integer column is SQLite's
291
+ // rowid alias too, so it becomes an identity column.
292
+ const tablePk = parsed.find((p) => p.constraint && /^\s*(constraint\s+\S+\s+)?primary\s+key\s*\(/i.test(codeMask(p.constraint)));
293
+ let identityFromTablePk = null;
294
+ if (tablePk) {
295
+ const m = /\(([^)]*)\)/.exec(tablePk.constraint);
296
+ const cols = m ? splitTopLevel(m[1]).map((c) => unquote(c.replace(/\s+(asc|desc|autoincrement)\b/gi, ''))) : [];
297
+ if (cols.length === 1) {
298
+ const target = parsed.find((p) => p.column && p.column.name === cols[0]);
299
+ if (target && target.type === 'bigint') identityFromTablePk = target;
300
+ }
301
+ }
302
+ for (const p of parsed) {
303
+ if (p.constraint) {
304
+ if (p === tablePk && identityFromTablePk) continue;
305
+ out.push(convertTableConstraint(p.constraint, notes));
306
+ continue;
307
+ }
308
+ const col = p.column;
309
+ let constraints = col.constraints;
310
+ if (p === identityFromTablePk) constraints = `${constraints} PRIMARY KEY`;
311
+ const conv = convertColumnConstraints(constraints, { type: p.type, ctx, opts });
312
+ for (const n of conv.notes) notes.push(`${col.name}: ${n}`);
313
+ columns.set(col.name, p.type);
314
+ const ident = /^[a-z_][a-z0-9_]*$/.test(col.name) && !RESERVED.has(col.name) ? col.name : quoteIdent(col.name);
315
+ out.push(`${ident} ${p.type}${conv.constraints ? ` ${conv.constraints}` : ''}`.trim());
316
+ }
317
+ ctx.tables.set(name, { name, columns });
318
+ let tailOut = replaceCode(tail, /\s*(without\s+rowid|strict)\b/gi, (m) => {
319
+ notes.push(`dropped ${m.trim().toUpperCase()}`);
320
+ return '';
321
+ });
322
+ tailOut = tailOut.replace(/;\s*$/, '').trim();
323
+ const temp = head[1] ? 'temporary ' : '';
324
+ const ifNot = head[2] ? 'if not exists ' : '';
325
+ const lines = [`create ${temp}table ${ifNot}${nameSql} (`, out.map((d) => ` ${d}`).join(',\n'), `)${tailOut ? ` ${tailOut}` : ''};`];
326
+ const comments = notes.map((n) => `-- ${n}`);
327
+ return [...comments, ...lines].join('\n');
328
+ }
329
+
330
+ /** Column names that need quoting in Postgres but were fine in SQLite. */
331
+ const RESERVED = new Set([
332
+ 'user', 'order', 'group', 'limit', 'offset', 'from', 'to', 'select', 'where', 'table', 'column', 'default',
333
+ 'check', 'references', 'primary', 'foreign', 'key', 'index', 'constraint', 'end', 'case', 'when', 'then', 'else',
334
+ 'and', 'or', 'not', 'in', 'is', 'null', 'true', 'false', 'all', 'any', 'some', 'as', 'on', 'using', 'join',
335
+ 'left', 'right', 'full', 'inner', 'outer', 'cross', 'natural', 'union', 'except', 'intersect', 'having',
336
+ 'distinct', 'into', 'values', 'returning', 'with', 'only', 'for', 'do', 'grant', 'session_user', 'current_user',
337
+ 'current_date', 'current_time', 'current_timestamp', 'localtime', 'localtimestamp', 'desc', 'asc', 'both',
338
+ 'leading', 'trailing', 'window', 'over', 'partition', 'fetch', 'lateral', 'cast', 'collate', 'array', 'analyse',
339
+ 'analyze', 'authorization', 'binary', 'concurrently', 'create', 'current_catalog', 'current_role',
340
+ 'current_schema', 'deferrable', 'else', 'except', 'freeze', 'ilike', 'initially', 'isnull', 'like', 'notnull',
341
+ 'placing', 'similar', 'symmetric', 'tablesample', 'unique', 'variadic', 'verbose',
342
+ ]);
343
+
344
+ /**
345
+ * CREATE INDEX: keep it; `col COLLATE NOCASE` becomes `lower(col)`.
346
+ * @param {string} sql
347
+ */
348
+ export function convertCreateIndex(sql) {
349
+ let out = sql.replace(/;\s*$/, '');
350
+ out = replaceCode(out, /("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+collate\s+nocase\b/gi, (_m, col) => `lower(${col})`);
351
+ out = replaceCode(out, /\s+collate\s+(binary|rtrim)\b/gi, '');
352
+ out = replaceCode(out, /`([^`]*)`/g, (_m, n) => quoteIdent(n));
353
+ return `${rewriteFunctions(out)};`;
354
+ }
355
+
356
+ /**
357
+ * CREATE VIRTUAL TABLE ... USING fts5(...): a tsvector column plus a GIN
358
+ * index on the content table when it can be found, else a TODO.
359
+ *
360
+ * @param {string} sql
361
+ * @param {ConvertContext} ctx
362
+ * @param {ConvertOptions} opts
363
+ */
364
+ export function convertVirtualTable(sql, ctx, opts) {
365
+ const mask = codeMask(sql);
366
+ const m = /^\s*create\s+virtual\s+table\s+(if\s+not\s+exists\s+)?(\S+)\s+using\s+([A-Za-z0-9_]+)\s*\(/i.exec(mask);
367
+ const commented = sql
368
+ .trim()
369
+ .split('\n')
370
+ .map((l) => `-- ${l}`)
371
+ .join('\n');
372
+ if (!m) return `-- TODO: virtual table not understood\n${commented}`;
373
+ const name = unquote(sql.slice(m.index + m[0].indexOf(m[2]), m.index + m[0].indexOf(m[2]) + m[2].length));
374
+ const module = m[3].toLowerCase();
375
+ if (module !== 'fts5' && module !== 'fts4' && module !== 'fts3') {
376
+ return `-- TODO: virtual table "${name}" uses ${module}, which has no Postgres counterpart\n${commented}`;
377
+ }
378
+ const open = m[0].length - 1;
379
+ const close = matchParen(mask, open);
380
+ const args = splitTopLevel(sql.slice(open + 1, close));
381
+ const options = new Map();
382
+ const ftsColumns = [];
383
+ for (const a of args) {
384
+ const kv = /^([A-Za-z_]+)\s*=\s*(.+)$/s.exec(a.trim());
385
+ if (kv) {
386
+ options.set(kv[1].toLowerCase(), kv[2].trim().replace(/^['"]|['"]$/g, ''));
387
+ continue;
388
+ }
389
+ if (/\bunindexed\b/i.test(a)) continue;
390
+ ftsColumns.push(unquote(a.trim().split(/\s+/)[0]));
391
+ }
392
+ let contentTable = options.get('content') || null;
393
+ if (contentTable === '') contentTable = null;
394
+ if (!contentTable) {
395
+ const guesses = [name.replace(/_?fts\d?$/i, ''), name.replace(/^fts\d?_/i, ''), name.replace(/_(search|index|idx)$/i, '')];
396
+ contentTable = guesses.find((g) => g && g !== name && ctx.tables.has(g)) ?? null;
397
+ }
398
+ const search = opts.searchColumn ?? 'search';
399
+ const config = opts.textSearchConfig ?? 'english';
400
+ const header = `-- FTS5 table "${name}" (${ftsColumns.join(', ')}) has no Postgres counterpart; it is replaced below.\n${commented}`;
401
+ if (!contentTable) {
402
+ return `${header}\n-- TODO: could not find the content table for "${name}". Add a tsvector column to it:\n` +
403
+ `-- alter table <content_table> add column ${search} tsvector generated always as (to_tsvector('${config}', coalesce(${ftsColumns.map((c) => `${c}, ''`).join(") || ' ' || coalesce(")}))) stored;\n` +
404
+ `-- create index on <content_table> using gin (${search});`;
405
+ }
406
+ const info = ctx.tables.get(contentTable);
407
+ const cols = info ? ftsColumns.filter((c) => info.columns.has(c)) : ftsColumns;
408
+ const missing = info ? ftsColumns.filter((c) => !info.columns.has(c)) : [];
409
+ if (!cols.length) {
410
+ return `${header}\n-- TODO: none of the FTS5 columns exist on "${contentTable}"; add a tsvector column by hand.`;
411
+ }
412
+ const expr = cols
413
+ .map((c) => {
414
+ const type = info?.columns.get(c) ?? 'text';
415
+ const ref = /^[a-z_][a-z0-9_]*$/.test(c) && !RESERVED.has(c) ? c : quoteIdent(c);
416
+ return `coalesce(${type === 'text' ? ref : `${ref}::text`}, '')`;
417
+ })
418
+ .join(" || ' ' || ");
419
+ const tableSql = /^[a-z_][a-z0-9_]*$/.test(contentTable) ? contentTable : quoteIdent(contentTable);
420
+ const lines = [
421
+ header,
422
+ ...(missing.length ? [`-- (columns not on ${contentTable}, skipped: ${missing.join(', ')})`] : []),
423
+ `alter table ${tableSql} add column if not exists ${search} tsvector`,
424
+ ` generated always as (to_tsvector('${config}', ${expr})) stored;`,
425
+ `create index if not exists ${contentTable.replace(/[^A-Za-z0-9_]/g, '_')}_${search}_idx on ${tableSql} using gin (${search});`,
426
+ `-- query: where ${search} @@ websearch_to_tsquery('${config}', ?) order by ts_rank_cd(${search}, websearch_to_tsquery('${config}', ?)) desc`,
427
+ ];
428
+ return lines.join('\n');
429
+ }
430
+
431
+ /**
432
+ * Triggers are emitted commented out with a TODO; a Postgres trigger needs a
433
+ * function and different NEW/OLD spelling, and most SQLite triggers only
434
+ * maintained an FTS5 shadow table, which the tsvector column makes redundant.
435
+ * @param {string} sql
436
+ */
437
+ export function convertTrigger(sql) {
438
+ const mask = codeMask(sql);
439
+ const m = /create\s+(temp(?:orary)?\s+)?trigger\s+(if\s+not\s+exists\s+)?(\S+)/i.exec(mask);
440
+ const name = m ? unquote(sql.slice(m.index + m[0].length - m[3].length, m.index + m[0].length)) : 'unknown';
441
+ const fts = /_fts\b|fts5|fts4/i.test(mask);
442
+ const why = fts
443
+ ? 'it maintained an FTS5 table, which the generated tsvector column replaces; probably drop it'
444
+ : 'rewrite as a Postgres trigger function (create function ... returns trigger, then create trigger ... execute function)';
445
+ const body = sql
446
+ .trim()
447
+ .split('\n')
448
+ .map((l) => `-- ${l}`)
449
+ .join('\n');
450
+ return `-- TODO: trigger "${name}": ${why}\n${body}`;
451
+ }
452
+
453
+ /**
454
+ * Convert one DDL/DML statement from a SQLite schema file.
455
+ *
456
+ * @param {string} stmt
457
+ * @param {ConvertContext} ctx
458
+ * @param {ConvertOptions} [opts]
459
+ * @returns {string | null} Postgres SQL, or null to drop the statement
460
+ */
461
+ export function convertStatement(stmt, ctx, opts = {}) {
462
+ const mask = codeMask(stmt);
463
+ const text = stmt.trim();
464
+ if (!/\S/.test(mask)) return text || null; // comment-only
465
+ if (/^\s*(pragma|begin|commit|end|rollback|vacuum)\b/i.test(mask)) return null;
466
+ if (/\bsqlite_(sequence|stat\d|master)\b/i.test(mask)) return null;
467
+ if (/^\s*create\s+virtual\s+table\b/i.test(mask)) return convertVirtualTable(text, ctx, opts);
468
+ if (/^\s*create\s+(temp(orary)?\s+)?trigger\b/i.test(mask)) return convertTrigger(text);
469
+ if (/^\s*create\s+(temp(orary)?\s+)?table\b/i.test(mask)) return convertCreateTable(text, ctx, opts);
470
+ if (/^\s*create\s+(unique\s+)?index\b/i.test(mask)) return convertCreateIndex(text);
471
+ if (/^\s*create\s+(temp(orary)?\s+)?view\b/i.test(mask)) return `${rewriteFunctions(text.replace(/;\s*$/, ''))};`;
472
+ if (/^\s*alter\s+table\b/i.test(mask)) return convertAlterTable(text, ctx, opts);
473
+ if (/^\s*drop\s+(table|index|view)\b/i.test(mask)) return `${text.replace(/;\s*$/, '')};`;
474
+ if (/^\s*drop\s+trigger\b/i.test(mask)) return `-- ${text}`;
475
+ // Seed data and anything else: the run-time rewriter.
476
+ try {
477
+ const r = rewriteStatement(text, {
478
+ keys: (table) => {
479
+ const info = ctx.tables.get(table);
480
+ return info ? { pk: [], unique: [], columns: [...info.columns.keys()] } : undefined;
481
+ },
482
+ });
483
+ return r.noop ? (r.sql ? `-- ${r.sql}` : null) : `${r.sql};`;
484
+ } catch (err) {
485
+ return `-- TODO: ${err.message}\n${text
486
+ .split('\n')
487
+ .map((l) => `-- ${l}`)
488
+ .join('\n')}`;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * ALTER TABLE t ADD COLUMN def / RENAME / DROP COLUMN.
494
+ * @param {string} sql
495
+ * @param {ConvertContext} ctx
496
+ * @param {ConvertOptions} opts
497
+ */
498
+ function convertAlterTable(sql, ctx, opts) {
499
+ const mask = codeMask(sql);
500
+ const m = /^\s*alter\s+table\s+(if\s+exists\s+)?(\S+)\s+add\s+(column\s+)?(if\s+not\s+exists\s+)?/i.exec(mask);
501
+ if (!m) return `${sql.replace(/;\s*$/, '')};`;
502
+ const table = unquote(sql.slice(m.index + m[0].indexOf(m[2]), m.index + m[0].indexOf(m[2]) + m[2].length));
503
+ const def = sql.slice(m[0].length).replace(/;\s*$/, '');
504
+ const col = parseColumnDef(def);
505
+ let type = mapType(col.declared, opts);
506
+ if (type === 'text' && opts.promoteTextTimestamps !== false && isNowDefault(col.constraints)) type = 'timestamptz';
507
+ const conv = convertColumnConstraints(col.constraints, { type, ctx, opts });
508
+ ctx.tables.get(table)?.columns.set(col.name, type);
509
+ const ident = /^[a-z_][a-z0-9_]*$/.test(col.name) && !RESERVED.has(col.name) ? col.name : quoteIdent(col.name);
510
+ const tableSql = /^[a-z_][a-z0-9_]*$/.test(table) ? table : quoteIdent(table);
511
+ const notes = conv.notes.map((n) => `-- ${col.name}: ${n}`);
512
+ return [...notes, `alter table ${tableSql} add column if not exists ${ident} ${type}${conv.constraints ? ` ${conv.constraints}` : ''};`].join('\n');
513
+ }
514
+
515
+ /**
516
+ * Convert a whole SQLite schema script to a Postgres one.
517
+ *
518
+ * @param {string} sql
519
+ * @param {ConvertOptions} [opts]
520
+ * @returns {string}
521
+ */
522
+ export function convertSchema(sql, opts = {}) {
523
+ /** @type {ConvertContext} */
524
+ const ctx = { tables: new Map(), usesPgcrypto: false, notes: [] };
525
+ const out = [];
526
+ for (const stmt of splitStatements(sql)) {
527
+ const converted = convertStatement(stmt, ctx, opts);
528
+ if (converted !== null) out.push(converted);
529
+ }
530
+ const header = [
531
+ '-- Converted from SQLite by @profullstack/libsql-pg. Review every TODO before applying.',
532
+ '-- Types: INTEGER -> bigint, REAL -> double precision, BLOB -> bytea, BOOLEAN -> boolean,',
533
+ '-- DATETIME/TIMESTAMP -> timestamptz, TEXT -> text; INTEGER PRIMARY KEY -> identity.',
534
+ ].join('\n');
535
+ const parts = [header];
536
+ const hasPgcrypto = out.some((o) => /create\s+extension\s+if\s+not\s+exists\s+pgcrypto/i.test(o));
537
+ if (ctx.usesPgcrypto && !hasPgcrypto) parts.push('create extension if not exists pgcrypto;');
538
+ return `${[...parts, ...out].join('\n\n').replace(/\n{3,}/g, '\n\n')}\n`;
539
+ }
540
+
541
+ /**
542
+ * Convert a single DDL statement issued at run time through the client.
543
+ * @param {string} sql
544
+ */
545
+ export function convertDdl(sql) {
546
+ const ctx = { tables: new Map(), usesPgcrypto: false, notes: [] };
547
+ const r = convertStatement(sql, ctx);
548
+ return r ?? '';
549
+ }