dbt-js 0.1.1 → 0.1.2
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 -21
- package/README.md +365 -325
- package/bin/dbt-js.js +4 -4
- package/package.json +53 -53
- package/src/api.js +271 -257
- package/src/batches.js +129 -120
- package/src/cli.js +178 -175
- package/src/config.js +139 -68
- package/src/dag.js +67 -67
- package/src/db.js +194 -182
- package/src/materialize.js +197 -197
- package/src/project.js +139 -107
- package/src/render.js +65 -62
- package/src/seed.js +68 -68
- package/src/tests.js +49 -49
package/README.md
CHANGED
|
@@ -1,325 +1,365 @@
|
|
|
1
|
-
# dbt-js
|
|
2
|
-
|
|
3
|
-
A minimalist dbt-like SQL transformation tool for Postgres, MySQL, SQLite, and DuckDB. Models are plain SQL `SELECT` files; dbt-js compiles them (resolving `ref()` / `source()` / `var()`), builds a dependency DAG, and executes everything inside the database in dependency order. Like dbt, it is transformation-only — it never extracts or moves data; raw data must already be in your database (or, with DuckDB, in files it can read in place).
|
|
4
|
-
|
|
5
|
-
Five dependencies: `pg`, `mysql2`, `better-sqlite3`, `@duckdb/node-api`, and `csv-parse` — the database drivers are loaded lazily, so each backend only pays for its own. Plain ESM JavaScript, no build step.
|
|
6
|
-
|
|
7
|
-
## Install
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
npm install -g dbt-js # global CLI: dbt-js <command>
|
|
11
|
-
npx dbt-js debug # or run without installing
|
|
12
|
-
npm install dbt-js # as a library, for embedding (see below)
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
Requires Node.js >= 20.
|
|
16
|
-
|
|
17
|
-
## Quick start
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
###
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
select
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
-
|
|
212
|
-
- `
|
|
213
|
-
- `
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
await run({
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
-
|
|
309
|
-
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
-
|
|
318
|
-
-
|
|
319
|
-
-
|
|
320
|
-
-
|
|
321
|
-
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
1
|
+
# dbt-js
|
|
2
|
+
|
|
3
|
+
A minimalist dbt-like SQL transformation tool for Postgres, MySQL, SQLite, and DuckDB. Models are plain SQL `SELECT` files; dbt-js compiles them (resolving `ref()` / `source()` / `var()`), builds a dependency DAG, and executes everything inside the database in dependency order. Like dbt, it is transformation-only — it never extracts or moves data; raw data must already be in your database (or, with DuckDB, in files it can read in place).
|
|
4
|
+
|
|
5
|
+
Five dependencies: `pg`, `mysql2`, `better-sqlite3`, `@duckdb/node-api`, and `csv-parse` — the database drivers are loaded lazily, so each backend only pays for its own. Plain ESM JavaScript, no build step.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g dbt-js # global CLI: dbt-js <command>
|
|
11
|
+
npx dbt-js debug # or run without installing
|
|
12
|
+
npm install dbt-js # as a library, for embedding (see below)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js >= 20.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
DuckDB needs no database server, so a project is just two files. Create a directory with:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
// dbtjs.config.json
|
|
23
|
+
{
|
|
24
|
+
"connection": { "type": "duckdb", "path": "./warehouse.duckdb" },
|
|
25
|
+
"schema": "analytics"
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```sql
|
|
30
|
+
-- models/hello.sql
|
|
31
|
+
select 1 as id, 'world' as greeting
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then, from that directory:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
dbt-js debug # check config + connectivity
|
|
38
|
+
dbt-js run # build all models in DAG order
|
|
39
|
+
dbt-js test # run data tests
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
(Add a `seeds/*.csv` file and `dbt-js seed` to load CSV data first; `ref()` it from a
|
|
43
|
+
model.) Swap the connection block for Postgres, MySQL, or SQLite — see **Project layout**
|
|
44
|
+
below — and the same commands work unchanged.
|
|
45
|
+
|
|
46
|
+
## Project layout
|
|
47
|
+
|
|
48
|
+
A dbt-js project is a directory containing:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
dbtjs.config.json # connection, target schema, sources, vars
|
|
52
|
+
models/*.sql # one SELECT per file; filename = model name
|
|
53
|
+
seeds/*.csv # one table per file; filename = table name
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Model and seed names must be word characters only (`[A-Za-z0-9_]`) — `ref()` / `source()`
|
|
57
|
+
match `\w+`, so a name like `my-model` would be unreferenceable. Use underscores (`my_model`).
|
|
58
|
+
|
|
59
|
+
### dbtjs.config.json
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"connection": {
|
|
64
|
+
"host": "localhost",
|
|
65
|
+
"port": 5432,
|
|
66
|
+
"user": "me",
|
|
67
|
+
"password": "${DBTJS_PASSWORD}",
|
|
68
|
+
"database": "mydb"
|
|
69
|
+
},
|
|
70
|
+
"schema": "analytics",
|
|
71
|
+
"sources": { "raw": { "schema": "public" } },
|
|
72
|
+
"vars": { "start": null },
|
|
73
|
+
"seeds": { "columnTypes": { "my_seed": { "joined_on": "date" } } }
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For MySQL, the same shape with `"type": "mysql"` (`port` defaults to 3306):
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"connection": {
|
|
82
|
+
"type": "mysql",
|
|
83
|
+
"host": "localhost",
|
|
84
|
+
"user": "me",
|
|
85
|
+
"password": "${DBTJS_PASSWORD}",
|
|
86
|
+
"database": "mydb"
|
|
87
|
+
},
|
|
88
|
+
"schema": "analytics"
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
For DuckDB and SQLite, the connection is just a file path (the warehouse is an embedded local file):
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"connection": { "type": "duckdb", "path": "./warehouse.duckdb" },
|
|
97
|
+
"schema": "analytics"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"connection": { "type": "sqlite", "path": "./warehouse.db" },
|
|
104
|
+
"schema": "analytics"
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `connection.type` is `"postgres"` (default), `"mysql"`, `"sqlite"`, or `"duckdb"`.
|
|
109
|
+
- `${NAME}` in connection values is replaced from the environment (error if unset). Omit `password` entirely to let `pg` use `PGPASSWORD`.
|
|
110
|
+
- `schema` is where all models and seeds are created (`CREATE SCHEMA IF NOT EXISTS` runs automatically).
|
|
111
|
+
- `sources` maps a source name to a schema, used by `{{ source('name', 'table') }}`; add `"database"` to a source to point it at a DuckDB attached catalog (see `connection.attach` in DuckDB notes).
|
|
112
|
+
- `vars` are defaults, overridable per-invocation with `--vars '{"start": "2026-06-01"}'`.
|
|
113
|
+
- `seeds.columnTypes` overrides inferred CSV column types (the escape hatch for dates/timestamps).
|
|
114
|
+
|
|
115
|
+
## Models
|
|
116
|
+
|
|
117
|
+
A model is a single `SELECT`. Configuration lives in one leading block comment with a JSON body:
|
|
118
|
+
|
|
119
|
+
```sql
|
|
120
|
+
/* config: {
|
|
121
|
+
"materialized": "incremental",
|
|
122
|
+
"strategy": "delete+insert",
|
|
123
|
+
"unique_key": "day",
|
|
124
|
+
"tests": { "day": ["not_null", "unique"] }
|
|
125
|
+
} */
|
|
126
|
+
select ...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
No config comment means `{ "materialized": "view" }`.
|
|
130
|
+
|
|
131
|
+
### Templating
|
|
132
|
+
|
|
133
|
+
| Expression | Becomes |
|
|
134
|
+
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
135
|
+
| `{{ ref('other_model') }}` | `"schema"."other_model"` — and declares a DAG dependency |
|
|
136
|
+
| `{{ this }}` | the current model's own table (for incremental high-water marks) |
|
|
137
|
+
| `{{ source('raw', 'orders') }}` | `"public"."orders"` (schema from `sources` config; a source with a `database` resolves to `"db"."schema"."orders"` — a DuckDB attached catalog) |
|
|
138
|
+
| `{{ var('start') }}` / `{{ var('x', 0) }}` | the var's value, or the default; error if neither. Inserted verbatim — quote it yourself in SQL |
|
|
139
|
+
| `{{ batch_start }}` / `{{ batch_end }}` | the current batch window as `YYYY-MM-DD HH:MM:SS` (microbatch models only). Inserted verbatim — quote it yourself |
|
|
140
|
+
| `{{ timezone }}` | the model's configured IANA zone (default `UTC`). Inserted verbatim — quote it yourself (see Timezone below) |
|
|
141
|
+
| `{% if is_incremental() %} ... {% endif %}` | body included only on incremental runs (table exists, not `--full-refresh`) |
|
|
142
|
+
|
|
143
|
+
That's the whole template language. Anything else inside `{{ }}` / `{% %}` is a compile error.
|
|
144
|
+
|
|
145
|
+
### Materializations
|
|
146
|
+
|
|
147
|
+
- **view** (default): `CREATE OR REPLACE VIEW`
|
|
148
|
+
- **table**: transactional `DROP TABLE ... CASCADE; CREATE TABLE ... AS SELECT` (atomic to readers; CASCADE-dropped downstream views are rebuilt later in the same run — for partial runs use `--select model+`)
|
|
149
|
+
- **incremental**: first run (or `--full-refresh`) builds like a table; after that only the rows your SELECT returns are applied, via a strategy:
|
|
150
|
+
- `append` — plain `INSERT INTO ... SELECT` (immutable event data)
|
|
151
|
+
- `delete+insert` — requires `unique_key` (string or array); deletes matching keys then inserts, in one transaction (idempotent re-runs)
|
|
152
|
+
- `microbatch` — splits the event-time range into aligned windows and replaces each window in its own transaction (see below)
|
|
153
|
+
|
|
154
|
+
### Hooks
|
|
155
|
+
|
|
156
|
+
`pre_hook` / `post_hook` run extra SQL around a model's build — grants, indexes, `ANALYZE`, audit rows. Each is a string or array of strings, rendered with the same template language as the model body (everything except `batch_start` / `batch_end`):
|
|
157
|
+
|
|
158
|
+
```sql
|
|
159
|
+
/* config: {
|
|
160
|
+
"materialized": "table",
|
|
161
|
+
"post_hook": [
|
|
162
|
+
"create index if not exists idx_daily_revenue_day on {{ this }} (day)",
|
|
163
|
+
"grant select on {{ this }} to reporting"
|
|
164
|
+
]
|
|
165
|
+
} */
|
|
166
|
+
select ...
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- Order: all pre-hooks → materialization → all post-hooks, each hook as its own statement.
|
|
170
|
+
- One deliberate divergence from dbt: hooks run **outside** the materialization transaction, so they can use statements Postgres forbids inside one (`VACUUM`, `CREATE INDEX CONCURRENTLY`). A failing pre-hook aborts the model before any build; a failing post-hook marks the model FAIL but the built relation remains — fix the hook and re-run.
|
|
171
|
+
- Microbatch models run hooks once per model (pre-hooks before the first batch, post-hooks after the last), not per batch; post-hooks are skipped when any batch failed.
|
|
172
|
+
- `{{ ref('x') }}` inside a hook declares a DAG dependency, same as in the body.
|
|
173
|
+
|
|
174
|
+
### Incremental pattern + backfill
|
|
175
|
+
|
|
176
|
+
```sql
|
|
177
|
+
select date_trunc('day', created_at)::date as day, count(*) as orders
|
|
178
|
+
from {{ ref('orders_enriched') }}
|
|
179
|
+
{% if is_incremental() %}
|
|
180
|
+
where created_at >= coalesce(
|
|
181
|
+
nullif('{{ var("start", "") }}', '')::timestamptz,
|
|
182
|
+
(select max(day) from {{ this }})::timestamptz)
|
|
183
|
+
{% endif %}
|
|
184
|
+
group by 1
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- Normal run: processes from the table's own high-water mark (`max(day)`).
|
|
188
|
+
- Backfill: `dbt-js run --select daily_revenue --vars '{"start": "2026-01-01"}'` re-derives from that date; `delete+insert` makes it idempotent.
|
|
189
|
+
- Full rebuild: `dbt-js run --select daily_revenue --full-refresh`.
|
|
190
|
+
|
|
191
|
+
### Microbatch
|
|
192
|
+
|
|
193
|
+
For batched, retryable backfills, use `strategy: "microbatch"`. dbt-js splits the time range into `batch_size` windows and runs each as its own transaction: `DELETE` the target rows whose `event_time` falls in the window, then `INSERT` the batch's rows. A failed batch is reported and the rest keep running.
|
|
194
|
+
|
|
195
|
+
```sql
|
|
196
|
+
/* config: {
|
|
197
|
+
"materialized": "incremental",
|
|
198
|
+
"strategy": "microbatch",
|
|
199
|
+
"event_time": "day",
|
|
200
|
+
"begin": "2026-01-01",
|
|
201
|
+
"batch_size": "day",
|
|
202
|
+
"lookback": 1
|
|
203
|
+
} */
|
|
204
|
+
select date_trunc('day', created_at)::date as day, count(*) as orders
|
|
205
|
+
from {{ ref('orders_enriched') }}
|
|
206
|
+
where created_at >= '{{ batch_start }}'::timestamptz
|
|
207
|
+
and created_at < '{{ batch_end }}'::timestamptz
|
|
208
|
+
group by 1
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- `event_time` — column **of this model's output** bounding each batch (used by the engine's per-window DELETE).
|
|
212
|
+
- `begin` — start of history; first run and `--full-refresh` build every batch from here.
|
|
213
|
+
- `batch_size` — `hour` | `day` | `month` | `year`. Boundaries align to the model's `timezone` (default UTC).
|
|
214
|
+
- `lookback` (default 1) — a normal run reprocesses the current batch plus this many previous ones (no high-water mark, same as dbt).
|
|
215
|
+
- Backfill: `dbt-js run --select my_model --event-time-start 2026-06-02 --event-time-end 2026-06-04` rewrites exactly those windows (whole batches; end is exclusive). Idempotent by construction.
|
|
216
|
+
- No `is_incremental()` needed — the `batch_start`/`batch_end` filter applies on every run, including the first.
|
|
217
|
+
- If batches fail, the model exits FAIL listing the failed windows and the exact `--event-time-start/--event-time-end` retry command; other batches' work is kept.
|
|
218
|
+
|
|
219
|
+
One deliberate divergence from dbt: dbt auto-filters upstream `ref()`s by their declared `event_time`; dbt-js does no hidden query rewriting — you filter your input yourself with `{{ batch_start }}` / `{{ batch_end }}`.
|
|
220
|
+
|
|
221
|
+
### Timezone
|
|
222
|
+
|
|
223
|
+
Any model may set `"timezone"` in its config (a string IANA zone, default `"UTC"`):
|
|
224
|
+
|
|
225
|
+
- For microbatch models it aligns each window to that zone's wall-clock. `{{ batch_start }}` / `{{ batch_end }}` are emitted as naive `YYYY-MM-DD HH:MM:SS` **wall-clock strings in that zone**, so they compare directly against a locally-stored `event_time` column. A `"day"` batch in `"America/New_York"` therefore spans local midnight-to-midnight, not UTC.
|
|
226
|
+
- `{{ timezone }}` is available in **any** model's SQL (raw substitution — quote it yourself, e.g. `created_at at time zone '{{ timezone }}'`).
|
|
227
|
+
- `begin`, `--event-time-start`, and `--event-time-end` given as naive strings are interpreted as wall-clock in the model's `timezone`; strings with an explicit `Z`/offset stay absolute.
|
|
228
|
+
- DST caveat: with `batch_size: "hour"` in a DST zone the spring-forward/fall-back hour is irregular — prefer UTC for hour-grain, or day+ grain for zoned models.
|
|
229
|
+
|
|
230
|
+
## Tests
|
|
231
|
+
|
|
232
|
+
Declared per column in the model's config. Each compiles to a query returning violating rows; any row fails the test (exit code 1, with up to 10 sample rows printed).
|
|
233
|
+
|
|
234
|
+
- `"not_null"` — rows where the column is NULL
|
|
235
|
+
- `"unique"` — non-NULL values appearing more than once
|
|
236
|
+
- `{ "accepted_values": ["a", "b"] }` — non-NULL values outside the list
|
|
237
|
+
|
|
238
|
+
## Seeds
|
|
239
|
+
|
|
240
|
+
`dbt-js seed` loads each `seeds/*.csv` as a table (drop + create + insert, transactional). Column types are inferred (`integer`/`bigint`/`numeric`/`boolean`, else `text`; empty string → NULL); override per column via `seeds.columnTypes`. Models can `{{ ref('seed_name') }}` seeds.
|
|
241
|
+
|
|
242
|
+
## CLI
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
dbt-js run [--select SPEC] [--full-refresh] [--vars JSON]
|
|
246
|
+
[--event-time-start TS] [--event-time-end TS] # microbatch backfill window
|
|
247
|
+
dbt-js test [--select SPEC] [--vars JSON]
|
|
248
|
+
dbt-js seed [--select SPEC]
|
|
249
|
+
dbt-js compile [--select SPEC] [--vars JSON] # print compiled SQL, no DB needed
|
|
250
|
+
dbt-js ls # nodes in execution order
|
|
251
|
+
dbt-js debug # config + connectivity check
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`--select` accepts comma-separated names; `+name` adds everything upstream, `name+` everything downstream (e.g. `--select orders_enriched+` rebuilds it and its dependents).
|
|
255
|
+
|
|
256
|
+
On failure, downstream models are skipped and reported; exit code is 1 if anything failed.
|
|
257
|
+
|
|
258
|
+
## Embedding in a Node.js app
|
|
259
|
+
|
|
260
|
+
The CLI is a thin wrapper over a programmatic API. Install dbt-js as a dependency:
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
npm install dbt-js
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
import { run, test, seed, compile, ls, query, debug } from "dbt-js";
|
|
268
|
+
|
|
269
|
+
const result = await run({
|
|
270
|
+
projectDir: "./analytics", // dir containing dbtjs.config.json — always pass this
|
|
271
|
+
select: "daily_revenue+", // optional, same syntax as --select
|
|
272
|
+
vars: { start: "2026-06-01" }, // optional, plain object (not a JSON string)
|
|
273
|
+
fullRefresh: false,
|
|
274
|
+
onEvent: (e) => logger.info(e), // optional progress stream; omit for silence
|
|
275
|
+
});
|
|
276
|
+
// result = { ok, models: [{ name, status: 'ok'|'fail'|'skip', materialized, action,
|
|
277
|
+
// rowCount, batchCount, failedBatches, durationMs, error }] }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The project can also be supplied inline instead of from files — handy when connection settings live in your app's config system or model SQL is generated:
|
|
281
|
+
|
|
282
|
+
```js
|
|
283
|
+
await run({
|
|
284
|
+
config: {
|
|
285
|
+
// contents of dbtjs.config.json (file not read)
|
|
286
|
+
connection: {
|
|
287
|
+
host: "db",
|
|
288
|
+
port: 5432,
|
|
289
|
+
user: "analytics",
|
|
290
|
+
password: process.env.PW,
|
|
291
|
+
database: "warehouse",
|
|
292
|
+
},
|
|
293
|
+
schema: "analytics",
|
|
294
|
+
sources: { raw: { schema: "public" } },
|
|
295
|
+
},
|
|
296
|
+
models: {
|
|
297
|
+
// replaces models/*.sql — same format, config comment included
|
|
298
|
+
stg_orders:
|
|
299
|
+
"select * from {{ source('raw', 'orders') }} where deleted = false",
|
|
300
|
+
order_counts:
|
|
301
|
+
'/* config: { "materialized": "table" } */ select count(*) as n from {{ ref(\'stg_orders\') }}',
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
With both given, `projectDir` is optional — it then only anchors relative DuckDB paths and locates `seeds/` (file seeds remain `ref()`-able from inline models). Inline `config` goes through the same validation and `${ENV}` interpolation as the file; your object is not mutated.
|
|
307
|
+
|
|
308
|
+
- `run` also takes `eventTimeStart` / `eventTimeEnd` for microbatch backfills. `test` → `{ ok, tests: [{ id, model, pass, violations, sample }] }`; `seed` → `{ ok, seeds: [...] }`; `compile` → `[{ name, materialized, sql, preHookSql, postHookSql }]` (no DB needed); `ls` → `[{ name, kind, deps }]`; `debug` → connectivity info (including `attached`, the list of DuckDB `ATTACH` catalogs — empty on other backends).
|
|
309
|
+
- `query({ sql, params?, readOnly = true, projectDir?, config? })` → `{ rows, rowCount }` runs one arbitrary statement against the warehouse. It bypasses model loading, so it works on a project with zero models (handy for inspecting results from your app). Read-only by default — DuckDB opens with `READ_ONLY` access mode, Postgres sets the session read-only — pass `readOnly: false` to write.
|
|
310
|
+
- Config or project errors **throw**; model/test failures come back as `ok: false` (mirrors the CLI's exit code 1).
|
|
311
|
+
- Every call opens its own connection and closes it before returning — nothing to pool.
|
|
312
|
+
- **Serialize runs yourself** (a one-promise queue is enough): DuckDB allows a single writer per file, so a scheduled refresh and an HTTP-triggered run must not overlap.
|
|
313
|
+
- Relative paths are anchored to `projectDir`, not your app's cwd: the DuckDB `connection.path` is resolved against it, and `read_csv('data/...')`-style paths in model SQL resolve via DuckDB's `file_search_path`.
|
|
314
|
+
|
|
315
|
+
## DuckDB notes
|
|
316
|
+
|
|
317
|
+
- `sources` resolve to schemas inside the same `.duckdb` file, exactly like Postgres schemas.
|
|
318
|
+
- Models can call DuckDB-native readers directly — `from read_csv('data/orders.csv')` or `read_parquet('...')` — no template syntax needed; raw data files never pass through dbt-js.
|
|
319
|
+
- DuckDB doesn't report row counts for full table builds (CTAS), so those log lines omit the count. Incremental and seed counts are reported normally.
|
|
320
|
+
- `:memory:` is a valid path but pointless for a CLI — each invocation is a separate process, so nothing would persist between `seed` and `run`.
|
|
321
|
+
- **Attaching external databases** — list databases to `ATTACH` under `connection.attach`; each becomes a catalog you read through `source()` with a `database` qualifier:
|
|
322
|
+
```json
|
|
323
|
+
{
|
|
324
|
+
"connection": {
|
|
325
|
+
"type": "duckdb",
|
|
326
|
+
"path": "./warehouse.duckdb",
|
|
327
|
+
"attach": [
|
|
328
|
+
{ "alias": "raw", "path": "./raw.duckdb" },
|
|
329
|
+
{ "alias": "legacy", "path": "./legacy.db", "type": "sqlite" }
|
|
330
|
+
]
|
|
331
|
+
},
|
|
332
|
+
"schema": "analytics",
|
|
333
|
+
"sources": { "raw_orders": { "database": "raw", "schema": "main" } }
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
Then `{{ source('raw_orders', 'orders') }}` resolves to `"raw"."main"."orders"`. Each entry needs a `path` (a file path for `duckdb`/`sqlite`, a connection string for `postgres`/`mysql`); optional `type` (default `"duckdb"`), `read_only`, and `alias`. `alias` defaults to the file's basename without extension (`./raw.duckdb` → `raw`) and is required for `postgres`/`mysql` connection strings. Attachments are **read-only by default** (and the `query` API forces all of them read-only) — models materialize into the main database's `schema`, never into an attached catalog. File paths anchor to the project dir; `${ENV}` interpolation works in connection strings. Non-DuckDB types autoload the matching scanner extension, which needs network access on first use.
|
|
337
|
+
- One Postgres-specific change: pre-existing **materialized views** squatting on a model's name are no longer auto-dropped (relation detection now uses `information_schema`, which can't see them); you'd get a clear Postgres error at build time instead. dbt-js itself never creates materialized views.
|
|
338
|
+
|
|
339
|
+
## MySQL notes
|
|
340
|
+
|
|
341
|
+
Requires MySQL 8.0+ (`CREATE TABLE ... AS SELECT` under GTID consistency additionally needs 8.0.21+, and temp-table-in-transaction is disallowed when it's enforced).
|
|
342
|
+
|
|
343
|
+
- dbt-js enables `ANSI_QUOTES` for its session, so double quotes are **identifier** quotes exactly as on Postgres/DuckDB — write string literals with single quotes in model SQL (the habit you already have from Postgres).
|
|
344
|
+
- `schema` maps to a MySQL **database**: `CREATE SCHEMA IF NOT EXISTS` is `CREATE DATABASE`, so the connecting user needs the server-wide CREATE privilege (or pre-create the schema and grant on it).
|
|
345
|
+
- MySQL DDL implicitly commits, so `table` and `--full-refresh` rebuilds (DROP + CREATE TABLE AS) are **not** atomic to readers the way they are on Postgres/DuckDB. `delete+insert` and microbatch window replacement remain fully transactional.
|
|
346
|
+
- No `CREATE INDEX IF NOT EXISTS` — use an idempotent post-hook like `analyze table {{ this }}`, or guard index creation yourself.
|
|
347
|
+
- Seed type inference maps `numeric` to `decimal(38,10)` (bare `NUMERIC` is `DECIMAL(10,0)` on MySQL and would round); `boolean` becomes `TINYINT(1)` with `true/false` loaded as `1/0`. Override per column via `seeds.columnTypes` as usual.
|
|
348
|
+
- Microbatch boundaries are computed in UTC and compared as `DATETIME` literals — prefer a `DATETIME` event-time column, or set the session time zone to UTC via mysql2's `timezone` connection option.
|
|
349
|
+
- Rows come back with `dateStrings: true` (dates as strings, JSON-safe, matching the DuckDB adapter); set `dateStrings: false` in the connection object to get JS `Date`s from the `query` API.
|
|
350
|
+
|
|
351
|
+
## SQLite notes
|
|
352
|
+
|
|
353
|
+
Driver: `better-sqlite3` (synchronous — a long-running statement blocks the embedding app's event loop; irrelevant for CLI use).
|
|
354
|
+
|
|
355
|
+
- `schema` maps to a **separate database file** `<schema>.db` next to `connection.path`, ATTACHed for the session (created automatically when writable). `"schema": "main"` keeps everything in the single main file.
|
|
356
|
+
- SQLite DDL is transactional, so **all** rebuilds — including `table` and `--full-refresh` — are atomic, like Postgres/DuckDB. One caveat: switching `journal_mode` to WAL in a hook removes crash atomicity for transactions spanning the main and attached files.
|
|
357
|
+
- There is no `DROP ... CASCADE`: dropping a table leaves dependent views dangling (they error when next queried) instead of dropping them.
|
|
358
|
+
- Type affinity gotchas: never `CAST(x AS DATETIME)` — `DATETIME` gets NUMERIC affinity, truncating `'2026-06-03'` to `2026`. Store timestamps as `'YYYY-MM-DD HH:MM:SS'` text; lexicographic comparison is chronological, and microbatch window boundaries are normalized with `datetime()` so day-granularity event-time columns work too.
|
|
359
|
+
- Seed `boolean` columns load as `1/0` (the text `'true'` would be falsy in `CASE WHEN`); `numeric` needs no special mapping (affinity stores decimals losslessly).
|
|
360
|
+
- The read-only `query` API opens the files with SQLite's readonly flag — writes fail with `SQLITE_READONLY`, and the database files must already exist.
|
|
361
|
+
- INTEGER values beyond 2^53 come back as imprecise JS numbers from the `query` API.
|
|
362
|
+
|
|
363
|
+
## License
|
|
364
|
+
|
|
365
|
+
MIT
|