dbt-js 0.1.1

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/batches.js ADDED
@@ -0,0 +1,120 @@
1
+ // Batch window computation for the microbatch incremental strategy.
2
+ // Date math aligns to a configurable IANA timezone (default 'UTC') — windows
3
+ // snap to that zone's wall-clock boundaries. No DB access, no SQL dialect concerns.
4
+
5
+ const UNITS = ['hour', 'day', 'month', 'year'];
6
+
7
+ // Wall-clock components of an instant as seen in `tz`: { year, month(1-12),
8
+ // day, hour, minute, second }. Built on Intl so it tracks DST automatically.
9
+ function partsInZone(date, tz) {
10
+ const dtf = new Intl.DateTimeFormat('en-US', {
11
+ timeZone: tz,
12
+ hourCycle: 'h23',
13
+ year: 'numeric',
14
+ month: '2-digit',
15
+ day: '2-digit',
16
+ hour: '2-digit',
17
+ minute: '2-digit',
18
+ second: '2-digit',
19
+ });
20
+ const p = {};
21
+ for (const part of dtf.formatToParts(date)) {
22
+ if (part.type !== 'literal') p[part.type] = Number(part.value);
23
+ }
24
+ if (p.hour === 24) p.hour = 0; // some engines emit 24 for midnight under h23
25
+ return p;
26
+ }
27
+
28
+ // The UTC instant for a wall-clock time interpreted in `tz`, DST-correct via an
29
+ // offset back-solve (one correction pass resolves spring-forward/fall-back).
30
+ function zonedWallToUtc({ year, month, day, hour, minute, second }, tz) {
31
+ const offsetAt = (ms) => {
32
+ const p = partsInZone(new Date(ms), tz);
33
+ return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - ms;
34
+ };
35
+ const naive = Date.UTC(year, month - 1, day, hour, minute, second);
36
+ let utc = naive - offsetAt(naive);
37
+ utc = naive - offsetAt(utc);
38
+ return new Date(utc);
39
+ }
40
+
41
+ // Parse a date string into a UTC instant. A string carrying an explicit zone
42
+ // (Z or ±HH:MM) is an absolute instant; a naive 'YYYY-MM-DD[ HH:MM[:SS]]' is
43
+ // interpreted as wall-clock in `tz`.
44
+ export function parseInZone(s, tz = 'UTC') {
45
+ const iso = String(s).trim().replace(' ', 'T');
46
+ if (/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(iso)) {
47
+ const d = new Date(iso);
48
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid date '${s}' (use YYYY-MM-DD or ISO 8601)`);
49
+ return d;
50
+ }
51
+ const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/);
52
+ if (!m) throw new Error(`Invalid date '${s}' (use YYYY-MM-DD or ISO 8601)`);
53
+ return zonedWallToUtc(
54
+ { year: +m[1], month: +m[2], day: +m[3], hour: +(m[4] || 0), minute: +(m[5] || 0), second: +(m[6] || 0) },
55
+ tz
56
+ );
57
+ }
58
+
59
+ // Back-compat alias — parsing in UTC.
60
+ export const parseUtc = (s) => parseInZone(s, 'UTC');
61
+
62
+ function truncTz(date, size, tz) {
63
+ const p = partsInZone(date, tz);
64
+ p.second = 0;
65
+ p.minute = 0;
66
+ if (size !== 'hour') p.hour = 0;
67
+ if (size === 'month' || size === 'year') p.day = 1;
68
+ if (size === 'year') p.month = 1;
69
+ return zonedWallToUtc(p, tz);
70
+ }
71
+
72
+ function addBatchesTz(date, size, n, tz) {
73
+ const p = partsInZone(date, tz);
74
+ if (size === 'hour') p.hour += n;
75
+ else if (size === 'day') p.day += n;
76
+ else if (size === 'month') p.month += n;
77
+ else p.year += n;
78
+ return zonedWallToUtc(p, tz); // Date.UTC normalizes overflow (day 32, month 13, ...)
79
+ }
80
+
81
+ function fmtTz(date, tz) {
82
+ const p = partsInZone(date, tz);
83
+ const pad = (n) => String(n).padStart(2, '0');
84
+ return `${p.year}-${pad(p.month)}-${pad(p.day)} ${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`;
85
+ }
86
+
87
+ // Returns [{ start, end }] of aligned [start, end) windows as 'YYYY-MM-DD HH:MM:SS'.
88
+ // first build / --full-refresh : trunc(begin) .. current batch end
89
+ // normal run : trunc(now) - lookback .. current batch end
90
+ // explicit backfill : trunc(start) .. ceil(end) (whole batches)
91
+ // The window never starts before `begin`.
92
+ export function computeBatches({ begin, batchSize, lookback = 1, start, end, firstBuild, timezone = 'UTC', now = new Date() }) {
93
+ if (!UNITS.includes(batchSize)) throw new Error(`Invalid batch_size '${batchSize}' (use ${UNITS.join('|')})`);
94
+ const beginAt = truncTz(parseInZone(begin, timezone), batchSize, timezone);
95
+
96
+ let endAt;
97
+ if (end) {
98
+ const e = parseInZone(end, timezone);
99
+ const t = truncTz(e, batchSize, timezone);
100
+ endAt = e.getTime() === t.getTime() ? t : addBatchesTz(t, batchSize, 1, timezone);
101
+ } else {
102
+ endAt = addBatchesTz(truncTz(now, batchSize, timezone), batchSize, 1, timezone);
103
+ }
104
+
105
+ let startAt;
106
+ if (start) startAt = truncTz(parseInZone(start, timezone), batchSize, timezone);
107
+ else if (firstBuild) startAt = beginAt;
108
+ else startAt = addBatchesTz(truncTz(now, batchSize, timezone), batchSize, -lookback, timezone);
109
+ if (startAt < beginAt) startAt = beginAt;
110
+
111
+ if (start && startAt >= endAt) {
112
+ throw new Error(`--event-time-start (${fmtTz(startAt, timezone)}) must be before the end of the window (${fmtTz(endAt, timezone)})`);
113
+ }
114
+
115
+ const batches = [];
116
+ for (let t = startAt; t < endAt; t = addBatchesTz(t, batchSize, 1, timezone)) {
117
+ batches.push({ start: fmtTz(t, timezone), end: fmtTz(addBatchesTz(t, batchSize, 1, timezone), timezone) });
118
+ }
119
+ return batches;
120
+ }
package/src/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ // Thin CLI over src/api.js: parse flags, format events as log lines, map
2
+ // result.ok to the exit code. All orchestration lives in the api.
3
+ import { parseArgs } from 'node:util';
4
+ import * as api from './api.js';
5
+
6
+ const USAGE = `Usage: dbt-js <command> [options]
7
+
8
+ Commands:
9
+ run Build models in dependency order
10
+ test Run data tests (not_null, unique, accepted_values)
11
+ seed Load seeds/*.csv into the target schema
12
+ compile Print compiled SQL without executing (is_incremental() = false)
13
+ ls List nodes in execution order
14
+ debug Check config and database connectivity
15
+
16
+ Options:
17
+ --select SPEC Comma-separated nodes; +name includes upstream, name+ downstream
18
+ --full-refresh Rebuild incremental models from scratch (run only)
19
+ --vars JSON Override project vars, e.g. --vars '{"start":"2026-06-01"}'
20
+ --event-time-start TS Backfill microbatch models from this time (run only)
21
+ --event-time-end TS End of the backfill window (requires --event-time-start)`;
22
+
23
+ export async function main(argv = process.argv.slice(2)) {
24
+ let values, command;
25
+ try {
26
+ const parsed = parseArgs({
27
+ args: argv,
28
+ allowPositionals: true,
29
+ options: {
30
+ select: { type: 'string' },
31
+ 'full-refresh': { type: 'boolean', default: false },
32
+ vars: { type: 'string' },
33
+ 'event-time-start': { type: 'string' },
34
+ 'event-time-end': { type: 'string' },
35
+ help: { type: 'boolean', default: false },
36
+ },
37
+ });
38
+ values = parsed.values;
39
+ command = parsed.positionals[0];
40
+ } catch (e) {
41
+ console.error(`Error: ${e.message}\n\n${USAGE}`);
42
+ process.exit(2);
43
+ }
44
+
45
+ if (!command || values.help) {
46
+ console.log(USAGE);
47
+ process.exit(command ? 0 : 2);
48
+ }
49
+
50
+ const commands = { run, test, seed, compile, ls, debug };
51
+ if (!commands[command]) {
52
+ console.error(`Error: unknown command '${command}'\n\n${USAGE}`);
53
+ process.exit(2);
54
+ }
55
+
56
+ try {
57
+ const ok = await commands[command](values);
58
+ process.exit(ok ? 0 : 1);
59
+ } catch (e) {
60
+ console.error(`Error: ${e.message}`);
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ const pad = (s, n) => String(s).padEnd(n);
66
+
67
+ function baseOpts(values) {
68
+ const opts = { select: values.select };
69
+ if (values.vars) {
70
+ try {
71
+ opts.vars = JSON.parse(values.vars);
72
+ } catch {
73
+ throw new Error(`--vars must be valid JSON, got: ${values.vars}`);
74
+ }
75
+ }
76
+ return opts;
77
+ }
78
+
79
+ function printModelEvent(e) {
80
+ if (e.type === 'batch') {
81
+ const detail = e.ok ? (e.rowCount != null ? `${e.rowCount} rows` : 'ok') : e.message;
82
+ console.log(` batch ${e.start} .. ${e.end} ${e.ok ? 'OK' : 'FAIL'} (${detail})`);
83
+ return;
84
+ }
85
+ const tag = `[${e.index}/${e.total}]`;
86
+ if (e.status === 'skip') {
87
+ console.log(`${tag} ${pad('SKIP', 5)} ${pad(e.materialized, 12)} ${e.name} (upstream failed)`);
88
+ } else if (e.status === 'fail' && e.failedBatches?.length) {
89
+ const f = e.failedBatches;
90
+ console.log(
91
+ `${tag} ${pad('FAIL', 5)} ${pad(e.action, 12)} ${e.name} — ${e.error}; ` +
92
+ `retry with --select ${e.name} --event-time-start "${f[0].start}" --event-time-end "${f[f.length - 1].end}"`
93
+ );
94
+ } else if (e.status === 'fail') {
95
+ console.log(`${tag} ${pad('FAIL', 5)} ${pad(e.materialized, 12)} ${e.name} — ${e.error}`);
96
+ } else {
97
+ const rows = e.rowCount != null ? `, ${e.rowCount} rows` : '';
98
+ const batches = e.batchCount != null ? `, ${e.batchCount} batches` : '';
99
+ console.log(`${tag} ${pad('OK', 5)} ${pad(e.action, 12)} ${e.name} (${e.durationMs}ms${rows}${batches})`);
100
+ }
101
+ }
102
+
103
+ async function run(values) {
104
+ if (values['event-time-end'] && !values['event-time-start']) {
105
+ throw new Error('--event-time-end requires --event-time-start');
106
+ }
107
+ const result = await api.run({
108
+ ...baseOpts(values),
109
+ fullRefresh: values['full-refresh'],
110
+ eventTimeStart: values['event-time-start'],
111
+ eventTimeEnd: values['event-time-end'],
112
+ onEvent: printModelEvent,
113
+ });
114
+ const counts = { ok: 0, fail: 0, skip: 0 };
115
+ for (const m of result.models) counts[m.status]++;
116
+ console.log(`\nDone: ${counts.ok} ok, ${counts.fail} failed, ${counts.skip} skipped`);
117
+ return result.ok;
118
+ }
119
+
120
+ async function test(values) {
121
+ const result = await api.test({
122
+ ...baseOpts(values),
123
+ onEvent: (e) => {
124
+ if (e.pass) {
125
+ console.log(`PASS ${e.id}`);
126
+ } else {
127
+ console.log(`FAIL ${e.id} (${e.violations} violating rows)`);
128
+ for (const row of e.sample) console.log(` ${JSON.stringify(row)}`);
129
+ }
130
+ },
131
+ });
132
+ if (!result.tests.length) {
133
+ console.log('No tests defined.');
134
+ return true;
135
+ }
136
+ const failures = result.tests.filter((t) => !t.pass).length;
137
+ console.log(`\nDone: ${result.tests.length - failures} passed, ${failures} failed`);
138
+ return result.ok;
139
+ }
140
+
141
+ async function seed(values) {
142
+ const result = await api.seed({
143
+ select: values.select,
144
+ onEvent: (e) =>
145
+ console.log(`[${e.index}/${e.total}] ${pad('OK', 5)} ${pad('seed', 12)} ${e.name} (${e.durationMs}ms, ${e.rowCount} rows)`),
146
+ });
147
+ return result.ok;
148
+ }
149
+
150
+ async function compile(values) {
151
+ for (const m of await api.compile(baseOpts(values))) {
152
+ console.log(`-- model: ${m.name} (${m.materialized})`);
153
+ m.preHookSql.forEach((sql, i) => console.log(`-- pre_hook[${i}]:\n${sql}`));
154
+ console.log(m.sql);
155
+ m.postHookSql.forEach((sql, i) => console.log(`-- post_hook[${i}]:\n${sql}`));
156
+ console.log('');
157
+ }
158
+ return true;
159
+ }
160
+
161
+ async function ls() {
162
+ for (const n of await api.ls()) {
163
+ const deps = n.deps.length ? ` <- ${n.deps.join(', ')}` : '';
164
+ console.log(`${pad(n.kind, 12)} ${n.name}${deps}`);
165
+ }
166
+ return true;
167
+ }
168
+
169
+ async function debug() {
170
+ const d = await api.debug();
171
+ console.log(`config: OK (schema "${d.schema}", ${d.modelCount} models, ${d.seedCount} seeds)`);
172
+ console.log(`target: ${d.target}`);
173
+ console.log(`connect: OK (${d.database}, ${d.version.split(' on ')[0]})`);
174
+ return true;
175
+ }
package/src/config.js ADDED
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ export function loadConfig(cwd = process.cwd()) {
5
+ const path = join(cwd, 'dbtjs.config.json');
6
+ let raw;
7
+ try {
8
+ raw = readFileSync(path, 'utf8');
9
+ } catch {
10
+ throw new Error(`No dbtjs.config.json found in ${cwd}`);
11
+ }
12
+ let cfg;
13
+ try {
14
+ cfg = JSON.parse(raw);
15
+ } catch (e) {
16
+ throw new Error(`Invalid JSON in ${path}: ${e.message}`);
17
+ }
18
+ return validateConfig(cfg, cwd);
19
+ }
20
+
21
+ // Shared by the file path above and inline `config` objects passed to the api.
22
+ // Mutates and returns cfg (defaults, env interpolation, duckdb path resolution).
23
+ export function validateConfig(cfg, cwd = process.cwd()) {
24
+ if (!cfg.connection || typeof cfg.connection !== 'object') {
25
+ throw new Error('config must have a "connection" object');
26
+ }
27
+ cfg.connection.type ??= 'postgres';
28
+ if (!['postgres', 'duckdb', 'mysql', 'sqlite'].includes(cfg.connection.type)) {
29
+ throw new Error(
30
+ `connection.type must be "postgres", "duckdb", "mysql" or "sqlite", got "${cfg.connection.type}"`
31
+ );
32
+ }
33
+ if (['duckdb', 'sqlite'].includes(cfg.connection.type) && typeof cfg.connection.path !== 'string') {
34
+ throw new Error(
35
+ `${cfg.connection.type} connection requires a "path" string (file path or ":memory:")`
36
+ );
37
+ }
38
+ if (cfg.connection.type === 'mysql') {
39
+ if (typeof cfg.connection.database !== 'string') {
40
+ throw new Error('mysql connection requires a "database" string');
41
+ }
42
+ cfg.connection.port ??= 3306;
43
+ }
44
+ if (!cfg.schema || typeof cfg.schema !== 'string') {
45
+ throw new Error('config must have a "schema" string (target schema for models)');
46
+ }
47
+ for (const [key, value] of Object.entries(cfg.connection)) {
48
+ if (typeof value === 'string') cfg.connection[key] = interpolateEnv(value, key);
49
+ }
50
+ if (['duckdb', 'sqlite'].includes(cfg.connection.type) && cfg.connection.path !== ':memory:') {
51
+ // anchor to the project dir so embedding apps can run from any cwd
52
+ cfg.connection.path = resolve(cwd, cfg.connection.path);
53
+ }
54
+ cfg.vars ??= {};
55
+ cfg.sources ??= {};
56
+ cfg.seeds ??= {};
57
+ return cfg;
58
+ }
59
+
60
+ function interpolateEnv(value, key) {
61
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => {
62
+ const v = process.env[name];
63
+ if (v === undefined) {
64
+ throw new Error(`connection.${key} references \${${name}} but that environment variable is not set`);
65
+ }
66
+ return v;
67
+ });
68
+ }
package/src/dag.js ADDED
@@ -0,0 +1,67 @@
1
+ import { extractRefs } from './render.js';
2
+
3
+ // Returns { nodes: Map<name, node>, order: string[] } — dependencies before dependents.
4
+ // Seeds are DAG nodes with no deps so ref('seed_name') resolves and orders correctly;
5
+ // `run` does not load them (that's the seed command's job, like dbt).
6
+ export function buildDag(models, seeds) {
7
+ const nodes = new Map();
8
+ for (const seed of seeds) nodes.set(seed.name, { ...seed, type: 'seed', deps: [] });
9
+ for (const model of models) {
10
+ nodes.set(model.name, { ...model, type: 'model', deps: [...new Set(extractRefs(model.rawSql))] });
11
+ }
12
+ return { nodes, order: topoSort(nodes) };
13
+ }
14
+
15
+ export function topoSort(nodes) {
16
+ const order = [];
17
+ const state = new Map(); // 0/undefined = unvisited, 1 = in stack, 2 = done
18
+ function visit(name, path) {
19
+ if (state.get(name) === 2) return;
20
+ if (state.get(name) === 1) {
21
+ throw new Error(`Cycle detected: ${[...path, name].join(' -> ')}`);
22
+ }
23
+ if (!nodes.has(name)) {
24
+ throw new Error(`'${path.at(-1)}' refs unknown model/seed '${name}'`);
25
+ }
26
+ state.set(name, 1);
27
+ for (const dep of nodes.get(name).deps) visit(dep, [...path, name]);
28
+ state.set(name, 2);
29
+ order.push(name);
30
+ }
31
+ for (const name of nodes.keys()) visit(name, []);
32
+ return order;
33
+ }
34
+
35
+ // spec: "a,b" | "+name" (name + upstream) | "name+" (name + downstream); null = everything.
36
+ export function expandSelection(spec, nodes, order) {
37
+ if (!spec) return order;
38
+ const reversed = new Map([...nodes.keys()].map((k) => [k, []]));
39
+ for (const [name, node] of nodes) {
40
+ for (const dep of node.deps) reversed.get(dep)?.push(name);
41
+ }
42
+ const selected = new Set();
43
+ for (const token of spec.split(',').map((s) => s.trim()).filter(Boolean)) {
44
+ const upstream = token.startsWith('+');
45
+ const downstream = token.endsWith('+');
46
+ const name = token.replace(/^\+/, '').replace(/\+$/, '');
47
+ if (!nodes.has(name)) throw new Error(`--select: unknown model/seed '${name}'`);
48
+ selected.add(name);
49
+ if (upstream) for (const n of walk(name, (x) => nodes.get(x).deps)) selected.add(n);
50
+ if (downstream) for (const n of walk(name, (x) => reversed.get(x))) selected.add(n);
51
+ }
52
+ return order.filter((n) => selected.has(n));
53
+ }
54
+
55
+ function walk(start, next) {
56
+ const found = new Set();
57
+ const queue = [start];
58
+ while (queue.length) {
59
+ for (const n of next(queue.shift())) {
60
+ if (!found.has(n)) {
61
+ found.add(n);
62
+ queue.push(n);
63
+ }
64
+ }
65
+ }
66
+ return found;
67
+ }
package/src/db.js ADDED
@@ -0,0 +1,182 @@
1
+ // All database access lives here. connect() dispatches on connection.type and
2
+ // returns a uniform client: { query(sql, params) -> { rows, rowCount }, end() }.
3
+ // Drivers are imported lazily so each backend only loads its own.
4
+
5
+ export async function connect(connection, { projectDir, readOnly = false, schema } = {}) {
6
+ const { type = 'postgres', ...rest } = connection;
7
+ return type === 'duckdb'
8
+ ? connectDuckdb(rest.path, projectDir, readOnly)
9
+ : type === 'mysql'
10
+ ? connectMysql(rest, readOnly)
11
+ : type === 'sqlite'
12
+ ? connectSqlite(rest, schema, readOnly)
13
+ : connectPg(rest, readOnly);
14
+ }
15
+
16
+ async function connectPg(connection, readOnly = false) {
17
+ const { default: pg } = await import('pg');
18
+ const client = new pg.Client(connection);
19
+ await client.connect();
20
+ // Session-level read-only also applies inside data-modifying CTEs,
21
+ // which a statement-keyword check can't catch.
22
+ if (readOnly) await client.query('SET default_transaction_read_only = on');
23
+ return {
24
+ dialect: 'postgres',
25
+ async query(sql, params) {
26
+ const res = await client.query(sql, params);
27
+ return { rows: res.rows, rowCount: res.rowCount ?? undefined };
28
+ },
29
+ end: () => client.end(),
30
+ };
31
+ }
32
+
33
+ async function connectMysql(connection, readOnly = false) {
34
+ const { default: mysql } = await import('mysql2/promise');
35
+ const conn = await mysql.createConnection({
36
+ dateStrings: true, // JSON-safe rows, matching the duckdb adapter
37
+ ...connection,
38
+ multipleStatements: false,
39
+ });
40
+ // render.js emits "schema"."name" with no dialect knowledge; ANSI_QUOTES
41
+ // makes double-quoted identifiers valid for the whole session.
42
+ await conn.query(`SET SESSION sql_mode = CONCAT_WS(',', NULLIF(@@sql_mode, ''), 'ANSI_QUOTES')`);
43
+ if (readOnly) await conn.query('SET SESSION transaction_read_only = 1');
44
+ return {
45
+ dialect: 'mysql',
46
+ async query(sql, params) {
47
+ const q = toQmarks(sql, params);
48
+ const [res] = await conn.query(q.sql, q.params); // rows[] or ResultSetHeader
49
+ return Array.isArray(res)
50
+ ? { rows: res, rowCount: res.length }
51
+ : { rows: [], rowCount: res.affectedRows ?? undefined }; // DML/DDL; CTAS reports inserted rows
52
+ },
53
+ end: () => conn.end(),
54
+ };
55
+ }
56
+
57
+ async function connectSqlite(connection, schema, readOnly = false) {
58
+ // Synchronous driver; long statements block the event loop (fine for CLI use,
59
+ // worth knowing when embedding).
60
+ const { default: Database } = await import('better-sqlite3');
61
+ const db = new Database(connection.path, readOnly ? { readonly: true } : {});
62
+ // cfg.schema 'main'/'temp' are SQLite's built-in schemas (single-file mode).
63
+ // Anything else lives in '<schema>.db' beside the main file, ATTACHed for the
64
+ // whole session so "schema"."name" from render.js resolves. ATTACH inherits
65
+ // the connection's readonly flag and can't create files read-only, so a
66
+ // missing file is skipped there (queries then fail with "no such table").
67
+ if (schema && schema !== 'main' && schema !== 'temp') {
68
+ const { join, dirname } = await import('node:path');
69
+ const { existsSync } = await import('node:fs');
70
+ const path =
71
+ connection.path === ':memory:' ? ':memory:' : join(dirname(connection.path), `${schema}.db`);
72
+ if (!readOnly || path === ':memory:' || existsSync(path)) {
73
+ db.prepare(`ATTACH DATABASE ? AS ${quoteIdent(schema)}`).run(path);
74
+ }
75
+ }
76
+ return {
77
+ dialect: 'sqlite',
78
+ async query(sql, params) {
79
+ const q = toQmarks(sql, params);
80
+ const stmt = db.prepare(q.sql); // rejects multi-statement strings, like mysql's multipleStatements:false
81
+ if (stmt.reader) {
82
+ const rows = stmt.all(...(q.params ?? []));
83
+ return { rows, rowCount: rows.length };
84
+ }
85
+ const info = stmt.run(...(q.params ?? []));
86
+ // sqlite3_changes is only updated by INSERT/UPDATE/DELETE; after DDL/CTAS
87
+ // it still holds the previous DML's count, so report undefined instead
88
+ const dml = /^\s*(insert|update|delete)\b/i.test(q.sql);
89
+ return { rows: [], rowCount: dml ? info.changes : undefined };
90
+ },
91
+ end: async () => db.close(),
92
+ };
93
+ }
94
+
95
+ // Internal queries (seeds, tests, relationKind) use Postgres-style $N
96
+ // placeholders; model SQL never carries params, so user SQL is never rewritten.
97
+ function toQmarks(sql, params) {
98
+ if (!params?.length) return { sql, params: undefined };
99
+ const ordered = [];
100
+ const out = sql.replace(/\$(\d+)/g, (_, n) => {
101
+ ordered.push(params[n - 1]);
102
+ return '?';
103
+ });
104
+ return { sql: out, params: ordered };
105
+ }
106
+
107
+ async function connectDuckdb(path, projectDir, readOnly = false) {
108
+ const { DuckDBInstance, ResultReturnType } = await import('@duckdb/node-api');
109
+ const instance = await DuckDBInstance.create(path, readOnly ? { access_mode: 'READ_ONLY' } : undefined);
110
+ const conn = await instance.connect();
111
+ if (projectDir) {
112
+ // resolve read_csv('data/...') etc. against the project dir, not the app's cwd
113
+ await conn.run(`SET file_search_path = '${projectDir.replace(/'/g, "''")}'`);
114
+ }
115
+ return {
116
+ dialect: 'duckdb',
117
+ async query(sql, params) {
118
+ const result = await conn.run(sql, params?.length ? params : undefined);
119
+ // Json variant returns BIGINT as string and dates as ISO strings —
120
+ // safe for JSON.stringify and Number() alike.
121
+ const rows = await result.getRowObjectsJson();
122
+ const rowCount =
123
+ result.returnType === ResultReturnType.CHANGED_ROWS ? result.rowsChanged
124
+ : result.returnType === ResultReturnType.QUERY_RESULT ? rows.length
125
+ : undefined; // DDL/CTAS: DuckDB doesn't report counts
126
+ return { rows, rowCount };
127
+ },
128
+ async end() {
129
+ conn.disconnectSync();
130
+ instance.closeSync(); // checkpoints WAL, releases the file lock
131
+ },
132
+ };
133
+ }
134
+
135
+ export const quoteIdent = (s) => `"${String(s).replace(/"/g, '""')}"`;
136
+ export const rel = (schema, name) => `${quoteIdent(schema)}.${quoteIdent(name)}`;
137
+
138
+ export async function ensureSchema(client, schema) {
139
+ // SQLite: CREATE SCHEMA doesn't exist; the schema was ATTACHed at connect
140
+ // time (which creates the file when writable), so it's already ensured.
141
+ if (client.dialect === 'sqlite') return;
142
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${quoteIdent(schema)}`);
143
+ }
144
+
145
+ // 'r' = table, 'v' = view, null = absent. information_schema works on all
146
+ // backends; the catalog predicate keeps DuckDB's temp catalog (schema "main")
147
+ // from colliding when the target schema is also "main". MySQL has no catalog
148
+ // (table_catalog is always 'def'; schema == database) so it skips the predicate.
149
+ // The alias forces a lowercase key — MySQL returns information_schema columns
150
+ // uppercase (TABLE_TYPE).
151
+ export async function relationKind(client, schema, name) {
152
+ if (client.dialect === 'sqlite') {
153
+ // no information_schema; pragma_table_list covers main + attached schemas
154
+ const { rows } = await client.query(
155
+ 'SELECT type FROM pragma_table_list WHERE schema = $1 AND name = $2',
156
+ [schema, name]
157
+ );
158
+ const t = rows[0]?.type; // lowercase 'table'/'view' (also 'shadow'/'virtual')
159
+ return t === 'table' ? 'r' : t === 'view' ? 'v' : null;
160
+ }
161
+ const catalogPredicate =
162
+ client.dialect === 'mysql' ? '' : 'table_catalog = current_database() AND ';
163
+ const { rows } = await client.query(
164
+ `SELECT table_type AS table_type FROM information_schema.tables
165
+ WHERE ${catalogPredicate}table_schema = $1 AND table_name = $2`,
166
+ [schema, name]
167
+ );
168
+ const t = rows[0]?.table_type;
169
+ return t === 'BASE TABLE' ? 'r' : t === 'VIEW' ? 'v' : null;
170
+ }
171
+
172
+ export async function withTransaction(client, fn) {
173
+ await client.query('BEGIN');
174
+ try {
175
+ const result = await fn();
176
+ await client.query('COMMIT');
177
+ return result;
178
+ } catch (e) {
179
+ await client.query('ROLLBACK');
180
+ throw e;
181
+ }
182
+ }