arn-db 0.0.0-stage → 0.2.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/GUIDE.md ADDED
@@ -0,0 +1,829 @@
1
+ # arn-db guide
2
+
3
+ Recipes organised by "I want to…". Every example runs against the test database
4
+ in this repo (`db/init.sql`), and the outputs shown are the real results:
5
+ `test/guide.test.ts` checks them on every test run.
6
+
7
+ The example tables:
8
+
9
+ ```
10
+ users id, name, email, age, active, meta jsonb, settings json, tags text[], scores jsonb
11
+ posts id, user_id → users, reviewer_id → users, title, published (user_N has N % 3 posts)
12
+ comments id, post_id → posts, author_id → users, body
13
+ labels id, name
14
+ post_labels post_id → posts, label_id → labels (posts ↔ labels)
15
+ ```
16
+
17
+ Contents:
18
+ [Setup](#setup) ·
19
+ [Selecting](#selecting) ·
20
+ [Filtering](#filtering) ·
21
+ [Ordering and random](#ordering-and-random) ·
22
+ [Related tables](#related-tables) ·
23
+ [Searching across 3+ tables](#searching-across-3-tables) ·
24
+ [Searching arrays](#searching-arrays) ·
25
+ [Counting](#counting) ·
26
+ [Aggregates and grouping](#aggregates-and-grouping) ·
27
+ [Insert, update, delete](#insert-update-delete) ·
28
+ [Changing jsonb and arrays](#changing-jsonb-and-arrays) ·
29
+ [Upsert and find-or-create](#upsert-and-find-or-create) ·
30
+ [Writing related rows](#writing-related-rows) ·
31
+ [Transactions](#transactions) ·
32
+ [Read replicas](#read-replicas) ·
33
+ [Raw SQL fragments](#raw-sql-fragments) ·
34
+ [Errors](#errors) ·
35
+ [Not supported yet](#not-supported-yet)
36
+
37
+ ---
38
+
39
+ ## Setup
40
+
41
+ Set two environment variables (in the app's `.env` or its container env):
42
+
43
+ ```
44
+ ARN_DB_URL=https://your-api.example.com/v2/raw
45
+ ARN_DB_KEY=<your API key>
46
+ ```
47
+
48
+ Then import the ready-made `db` anywhere. It has everything: `db.from()`,
49
+ `db.transaction()`, `db.ops.*` and `db.rel.*`.
50
+
51
+ ```ts
52
+ import { db } from 'arn-db'
53
+
54
+ await db.from('users').update({ scores: db.ops.append(4) }).eq('id', id)
55
+ await db.from('posts').insert({ title, user_id, comments: db.rel.create([{ body, author_id }]) })
56
+ ```
57
+
58
+ `db` reads the variables on its first query, not on import, so loading dotenv
59
+ later in startup still works. If they aren't set yet, it loads `./.env` from the
60
+ working directory first (Node 20.12+). Variables that are already set always win.
61
+ A missing variable throws on that first query.
62
+
63
+ Inside `transaction()` the same helpers are on `tx`: `tx.from()`, `tx.ref()`,
64
+ `tx.ops.*`, `tx.rel.*`. (`import { ops, rel } from 'arn-db'` still works too.)
65
+
66
+ For another key, another server, or other options, make your own client:
67
+
68
+ ```ts
69
+ import { createClient } from 'arn-db'
70
+
71
+ const reports = createClient(process.env.REPORTS_DB_URL!, process.env.REPORTS_DB_KEY!, { readFrom: 'replica' })
72
+ ```
73
+
74
+ Every call resolves to the same shape and never throws:
75
+
76
+ ```ts
77
+ const { data, error, count, status } = await db.from('users').select()
78
+ if (error) {
79
+ // error.message, error.code, error.details
80
+ }
81
+ ```
82
+
83
+ Server-side only: requests are POSTs with an `apikey` header; the API refuses browsers.
84
+
85
+ ---
86
+
87
+ ## Selecting
88
+
89
+ | I want | Write |
90
+ |---|---|
91
+ | all columns | `select()` or `select('*')` |
92
+ | some columns | `select('id, name, email')` |
93
+ | a column under another name | `select('id, fullName:name')` |
94
+ | a value inside jsonb, as text | `select('city:meta->>city')` |
95
+ | a value inside jsonb, keeping its type | `select('score:meta->score')` |
96
+ | deeper inside jsonb | `select('zip:meta->address->>zip')` |
97
+ | converted to a type | `select('score:meta->>score::int')` |
98
+
99
+ ```ts
100
+ await db.from('users')
101
+ .select('name, city:meta->>city, zip:meta->address->>zip, score:meta->score')
102
+ .eq('name', 'user_7')
103
+ .single()
104
+ // { name: 'user_7', city: 'Paris', zip: '1007', score: 7 }
105
+ // ^ ->> gives text ^ -> keeps the number
106
+ ```
107
+
108
+ **One row or many:**
109
+
110
+ ```ts
111
+ .single() // one object, or null if no row (never an error for 0 or many: it adds LIMIT 1)
112
+ .limit(1) // always an array: [ {...} ], or [] if no row
113
+ .range(10, 19) // rows 10..19 (0-based, inclusive)
114
+ ```
115
+
116
+ `single()` can't be combined with `limit()` or `range()`. With several matches
117
+ it returns the first one in your `order()`, so look rows up by a unique column
118
+ (or add `order()`) when it matters which one you get.
119
+
120
+ ---
121
+
122
+ ## Filtering
123
+
124
+ ```ts
125
+ .eq('active', true) .neq('name', 'x')
126
+ .gt('age', 30) .gte('age', 30) .lt('age', 30) .lte('age', 30)
127
+ .like('name', 'user\\_1%') .ilike('email', '%@EXAMPLE.com') // _ and % are wildcards
128
+ .in('name', ['user_3', 'user_5'])
129
+ .is('age', null) // also true / false
130
+ .match({ active: true, age: 30 }) // several eq at once
131
+ ```
132
+
133
+ Filters combine with AND (for OR and NOT see [groups](#or-not-and-groups)). Filters work on jsonb paths too:
134
+
135
+ ```ts
136
+ .eq('meta->>city', 'Tokyo') // text inside jsonb
137
+ .eq('meta->vip', true) // keeps the JSON type
138
+ .eq('meta->address->>zip', 1042) // deeper
139
+ .gte('meta->>score::int', 98) // cast for number comparison
140
+ ```
141
+
142
+ ```ts
143
+ await db.from('users').select('name, score:meta->>score::int')
144
+ .gte('meta->>score::int', 98).order('meta->>score::int')
145
+ // [{ name: 'user_98', score: 98 }, { name: 'user_99', score: 99 }, { name: 'user_100', score: 100 }]
146
+ ```
147
+
148
+ Without `::int`, `->>` compares as text, so `'9' > '10'`.
149
+
150
+ ### Comparing two columns, and the database time
151
+
152
+ `db.col(name)` is another column of the same row; `db.now()` is the database
153
+ server's `now()`. Both work with `eq`, `neq`, `gt`, `gte`, `lt` and `lte`, also inside groups.
154
+
155
+ ```ts
156
+ .lt('age', db.col('meta->>limit::int')) // age < (meta->>limit)::int
157
+ .lt(db.col('age').ifNull(0), db.col('meta->>limit::int')) // COALESCE(age, 0) < ...
158
+ .lt('age', db.col('meta->>limit::int').ifNull(100)) // ifNull on the right side
159
+ .gte(db.col('age').ifNull(0), 5) // ifNull with a plain value
160
+
161
+ .gt('created_at', db.now().minus('1 day')) // the last 24 hours
162
+ .lte('sent_at', db.now().minus('1 hour'))
163
+ .lt('expires_at', db.now().plus('30 minutes'))
164
+ ```
165
+
166
+ - Intervals are one number and one unit: `second`, `minute`, `hour`, `day`, `week`,
167
+ `month` or `year` (plural is fine). One `plus()` or `minus()` per `db.now()`.
168
+ - `db.col()` takes columns of the same table only (jsonb paths and casts are fine).
169
+ - `db.now()` also works as a value: `update({ last_read_time: db.now() })`, or in
170
+ `insert()` when every row uses the same `db.now()`. The time comes from the
171
+ database, so app servers with different clocks agree.
172
+
173
+ ### OR, NOT and groups
174
+
175
+ Chained filters are AND. `or()`, `and()` and `not()` take a callback that
176
+ builds one grouped condition; groups nest as deep as you need.
177
+
178
+ ```ts
179
+ // age > 60 OR city = 'Dhaka'
180
+ .or((q) => q.gt('age', 60).eq('meta->>city', 'Dhaka'))
181
+
182
+ // active AND (Tokyo OR Paris) AND age < 25
183
+ .eq('active', true).or((q) => q.eq('meta->>city', 'Tokyo').eq('meta->>city', 'Paris')).lt('age', 25)
184
+
185
+ // role = 'admin' OR (age >= 18 AND verified)
186
+ .or((q) => q.eq('role', 'admin').and((q) => q.gte('age', 18).is('verified', true)))
187
+
188
+ // NOT: a short form for one condition, a callback for several (joined with AND)
189
+ .not('deleted_at', 'is', null)
190
+ .not((q) => q.eq('active', true).gte('age', 40)) // NOT (active AND age >= 40)
191
+ .not((q) => q.or((q) => q.eq('meta->>city', 'Tokyo').eq('meta->>city', 'Berlin'))) // neither
192
+ ```
193
+
194
+ - Inside `or()` the conditions are joined with OR; inside `and()` and `not()` with AND.
195
+ - Every filter method works inside a group: jsonb paths, casts, `in`, `contains`, `match`, `filter`.
196
+ - Groups work in `count()`, `update()`, `delete()` and transactions (`tx.ref()` too).
197
+ - NULL is neither true nor false, as in Postgres: a row with a null `reviewer_id`
198
+ matches neither `.eq('reviewer_id', id)` nor `.not('reviewer_id', 'eq', id)`.
199
+ Add `.is('reviewer_id', null)` in an `or()` to keep those rows.
200
+ - All conditions in one group must be on the same table. A group on
201
+ `posts.*` filters the related posts, like `.eq('posts.published', true)`
202
+ (add `!inner` to also drop parents without a match):
203
+
204
+ ```ts
205
+ await db.from('users').select('name, posts!user_id(title)')
206
+ .or((q) => q.eq('posts.title', 'user_5_post_2').eq('posts.published', true))
207
+ ```
208
+
209
+ Mixing tables in one group (`name = 'x' OR posts.published`) is an error for now.
210
+
211
+ ---
212
+
213
+ ## Ordering and random
214
+
215
+ **Several orders:** chain them; they apply in the order written. Ascending is the default.
216
+
217
+ ```ts
218
+ await db.from('users').select('name, active, age')
219
+ .in('name', ['user_4', 'user_8', 'user_12', 'user_54', 'user_58'])
220
+ .order('active', { ascending: false }) // 1st: active first
221
+ .order('age', { ascending: false }) // 2nd: then oldest
222
+ .order('name') // 3rd: then by name
223
+ // [
224
+ // { name: 'user_58', active: true, age: 26 },
225
+ // { name: 'user_54', active: true, age: 22 },
226
+ // { name: 'user_12', active: false, age: 30 },
227
+ // { name: 'user_8', active: false, age: 26 },
228
+ // { name: 'user_4', active: false, age: 22 },
229
+ // ]
230
+ ```
231
+
232
+ Options: `{ ascending: false, nullsFirst: true }`. Columns can be jsonb paths: `.order('meta->>score::int')`.
233
+
234
+ **Random:**
235
+
236
+ ```ts
237
+ .random() // shuffle everything
238
+ .random().limit(5) // 5 random rows
239
+ .order('active', { ascending: false }).random() // active first, shuffled within each group
240
+ ```
241
+
242
+ **Random pages:** plain `random()` reshuffles on every request, so page 2 can
243
+ repeat page 1. Give a seed (e.g. a session id) for a stable order:
244
+
245
+ ```ts
246
+ await db.from('posts').select().random({ seed: sessionId }).range(0, 9) // page 1
247
+ await db.from('posts').select().random({ seed: sessionId }).range(10, 19) // page 2: no repeats, no gaps
248
+ ```
249
+
250
+ Same seed gives the same order; another seed gives another shuffle.
251
+
252
+ ---
253
+
254
+ ## Related tables
255
+
256
+ Write the related table's name in `select()`, with its columns in brackets.
257
+ The table name becomes the key, or an alias if you give one.
258
+
259
+ ### Reading the select string
260
+
261
+ ```ts
262
+ .select('name, posts!user_id!inner(title, labels(name))')
263
+ ```
264
+
265
+ | Piece | Meaning |
266
+ |---|---|
267
+ | `name` | a column of the main table |
268
+ | `posts(...)` | also bring rows from `posts`, under the key `posts` |
269
+ | `!user_id` | which link: `posts` has two FKs to `users` (`user_id`, `reviewer_id`) |
270
+ | `!inner` | only keep main rows that have a match (like INNER JOIN) |
271
+ | `title` | a column of each post |
272
+ | `labels(name)` | for each post, also bring its labels |
273
+ | `author:users(...)` | an alias: the key is `author` instead of `users` |
274
+
275
+ ### Directions
276
+
277
+ **Many-to-one** (post → its author): an object, or null.
278
+
279
+ ```ts
280
+ await db.from('posts').select('title, author:users!user_id(name)').eq('title', 'user_5_post_1').single()
281
+ // { title: 'user_5_post_1', author: { name: 'user_5' } }
282
+ ```
283
+
284
+ **One-to-many** (user → their posts): an array, `[]` when none.
285
+
286
+ ```ts
287
+ await db.from('users').select('name, posts!user_id(title)').eq('name', 'user_5')
288
+ .order('title', { referencedTable: 'posts' }).single()
289
+ // { name: 'user_5', posts: [{ title: 'user_5_post_1' }, { title: 'user_5_post_2' }] }
290
+ ```
291
+
292
+ **Many-to-many** (posts ↔ labels through `post_labels`): an array; the junction is found automatically.
293
+
294
+ ```ts
295
+ await db.from('posts').select('title, labels(name)').eq('title', 'user_5_post_2')
296
+ .order('name', { referencedTable: 'labels' }).single()
297
+ // { title: 'user_5_post_2', labels: [{ name: 'go' }, { name: 'node' }] }
298
+ ```
299
+
300
+ **Nested, any depth:**
301
+
302
+ ```ts
303
+ await db.from('users')
304
+ .select('name, posts!user_id(title, comments(body, author:users(name)), labels(name))')
305
+ .eq('name', 'user_5').order('title', { referencedTable: 'posts' }).single()
306
+ // {
307
+ // name: 'user_5',
308
+ // posts: [
309
+ // { title: 'user_5_post_1',
310
+ // comments: [{ body: 'comment on user_5_post_1', author: { name: 'user_2' } }],
311
+ // labels: [{ name: 'sql' }] },
312
+ // { title: 'user_5_post_2', comments: [], labels: [{ name: 'go' }, { name: 'node' }] },
313
+ // ],
314
+ // }
315
+ ```
316
+
317
+ **All columns of a relation:** `select('*, posts!user_id(*)')`.
318
+
319
+ **When do I need `!hint`?** Only when two tables have more than one link. If you
320
+ forget, the error tells you the choices:
321
+ `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
322
+
323
+ **A table that points to itself** (`categories.parent_id -> categories`): the
324
+ table name gives the children, the FK column gives the parent. The same keys
325
+ work for `rel.*` writes (`categories: db.rel.create([...])`, `parent_id: db.rel.connect(id)`).
326
+
327
+ ```ts
328
+ await db.from('categories').select('name, parent:parent_id(name), children:categories(name)').eq('name', 'go').single()
329
+ // { name: 'go', parent: { name: 'tech' }, children: [{ name: 'generics' }] }
330
+ ```
331
+
332
+ ### Filtering, ordering and limiting inside a relation
333
+
334
+ Use `relation.column` (the alias, if you gave one):
335
+
336
+ ```ts
337
+ // filter the related rows only: every user is still returned
338
+ await db.from('users').select('name, posts!user_id(title)')
339
+ .in('name', ['user_3', 'user_5']).eq('posts.published', false).order('name')
340
+ // [{ name: 'user_3', posts: [] }, { name: 'user_5', posts: [{ title: 'user_5_post_2' }] }]
341
+
342
+ // add !inner: users without a matching post are dropped too
343
+ await db.from('users').select('name, posts!user_id!inner(title)')
344
+ .in('name', ['user_3', 'user_5']).eq('posts.published', false).order('name')
345
+ // [{ name: 'user_5', posts: [{ title: 'user_5_post_2' }] }]
346
+
347
+ // filter the main rows through a many-to-one relation (alias + !inner)
348
+ await db.from('posts').select('title, author:users!user_id!inner(name)')
349
+ .eq('author.name', 'user_8').order('title')
350
+ // [{ title: 'user_8_post_1', author: { name: 'user_8' } }, { title: 'user_8_post_2', author: { name: 'user_8' } }]
351
+ ```
352
+
353
+ Order, limit and random inside a relation take `referencedTable`:
354
+
355
+ ```ts
356
+ await db.from('users').select('name, posts!user_id(title)').eq('name', 'user_5')
357
+ .order('title', { ascending: false, referencedTable: 'posts' })
358
+ .limit(1, { referencedTable: 'posts' })
359
+ .single()
360
+ // { name: 'user_5', posts: [{ title: 'user_5_post_2' }] } newest-titled post only
361
+
362
+ .random({ referencedTable: 'posts' }).limit(3, { referencedTable: 'posts' }) // 3 random posts per user
363
+ ```
364
+
365
+ Deeper levels use dots: `.eq('posts.comments.body', 'x')`, `{ referencedTable: 'posts.comments' }`.
366
+
367
+ ---
368
+
369
+ ## Searching across 3+ tables
370
+
371
+ Chain: `users` (1) ← `posts` (2) ← `comments` (3). Search comments for `user_5_post`:
372
+
373
+ **A) Start from the table you search, go up to the parents.** Simplest.
374
+
375
+ ```ts
376
+ await db.from('comments')
377
+ .select('body, post:posts(title, author:users!user_id(name))')
378
+ .ilike('body', '%user_5_post%')
379
+ // [{ body: 'comment on user_5_post_1', post: { title: 'user_5_post_1', author: { name: 'user_5' } } }]
380
+ ```
381
+
382
+ **B) Start from the top; show only what matched.** `!inner` at each level.
383
+
384
+ ```ts
385
+ await db.from('users')
386
+ .select('name, posts!user_id!inner(title, comments!inner(body))')
387
+ .ilike('posts.comments.body', '%user_5_post%')
388
+ // [{ name: 'user_5', posts: [{ title: 'user_5_post_1', comments: [{ body: 'comment on user_5_post_1' }] }] }]
389
+ ```
390
+
391
+ **C) Find with a deep search, but show everything for the rows found.** Embed
392
+ the same relation twice: one alias only for searching, one for showing.
393
+
394
+ ```ts
395
+ await db.from('users')
396
+ .select('name, match:posts!user_id!inner(comments!inner(id)), posts!user_id(title, comments(body))')
397
+ .ilike('match.comments.body', '%user_5_post%')
398
+ .order('title', { referencedTable: 'posts' })
399
+ // [{
400
+ // name: 'user_5',
401
+ // match: [ ... ], // only used for the search; ignore it
402
+ // posts: [
403
+ // { title: 'user_5_post_1', comments: [{ body: 'comment on user_5_post_1' }] },
404
+ // { title: 'user_5_post_2', comments: [] }, // not matching, still shown
405
+ // ],
406
+ // }]
407
+ ```
408
+
409
+ | I want | Use |
410
+ |---|---|
411
+ | the matching deep rows, with their parents | A |
412
+ | parents, showing only what matched | B |
413
+ | parents found by a deep search, with all their data | C |
414
+
415
+ ---
416
+
417
+ ## Searching arrays
418
+
419
+ Works on jsonb/json arrays (any element type) and Postgres arrays like `text[]`.
420
+ For a column holding `["one", "two"]`:
421
+
422
+ ```ts
423
+ .overlaps('scores', ['one', 'three']) // has ANY of them -> match
424
+ .contains('scores', ['one', 'two']) // has ALL of them -> match
425
+ .containedBy('scores', ['one', 'two', 'three']) // has nothing else -> match
426
+ .eq('scores', ['one', 'two']) // exactly, same order -> match
427
+ .overlaps('meta->tags', ['red']) // an array inside a jsonb object
428
+ ```
429
+
430
+ jsonb objects: `.contains('meta', { city: 'Dhaka', vip: true })` finds rows whose
431
+ `meta` has those keys and values.
432
+
433
+ ---
434
+
435
+ ## Counting
436
+
437
+ ```ts
438
+ const { data } = await db.from('users').count() // data: 100
439
+ const { data } = await db.from('users').eq('active', true).count() // data: 75
440
+
441
+ // Supabase form: no rows, count only
442
+ const { data, count } = await db.from('users').select('*', { count: 'exact', head: true }).eq('active', false)
443
+ // data: null, count: 25
444
+
445
+ // rows and the total together (for "page 1 of N")
446
+ const { data, count } = await db.from('posts').select('*', { count: 'exact' }).range(0, 9)
447
+
448
+ // per related row
449
+ await db.from('users').select('name, posts!user_id(count)').in('name', ['user_3', 'user_5']).order('name')
450
+ // [{ name: 'user_3', posts: [{ count: 0 }] }, { name: 'user_5', posts: [{ count: 2 }] }]
451
+ ```
452
+
453
+ Filters on relations apply: `.eq('posts.published', true)` counts only published posts.
454
+
455
+ ## Aggregates and grouping
456
+
457
+ One value over the matching rows; `data` is the value, or `null` when no rows match:
458
+
459
+ ```ts
460
+ const { data: newest } = await db.from('posts').eq('user_id', id).max('created_at')
461
+ await db.from('users').like('name', 'user\\_%').min('age')
462
+ await db.from('users').sum('age')
463
+ await db.from('users').avg('age')
464
+ await db.from('users').count({ distinct: 'name' }) // count(DISTINCT name)
465
+ ```
466
+
467
+ These take filters (and `.replica()`), and work as transaction steps.
468
+
469
+ Per group, in `select()`: `count()`, `sum(col)`, `avg(col)`, `min(col)`, `max(col)`,
470
+ `count(distinct col)`, each with an optional `name:` alias. Every plain column in the select must be in `groupBy()`,
471
+ and `order()` can sort by an aggregate's name:
472
+
473
+ ```ts
474
+ await db.from('users')
475
+ .select('team:meta->>team, n:count(), total:sum(age), oldest:max(age)', { count: 'exact' })
476
+ .groupBy('meta->>team')
477
+ .order('total', { ascending: false })
478
+ // with the four users test/merge.test.ts adds (teams red: ages 10, 30; blue: 20, null):
479
+ // data: [{ team: 'red', n: 2, total: 40, oldest: 30 }, { team: 'blue', n: 2, total: 20, oldest: 20 }]
480
+ // count: 2 (groups)
481
+
482
+ await db.from('users').select('count(), max(age)') // [{ count: 100, max: ... }]: one row, no groupBy
483
+ await db.from('posts').select('user_id').distinct() // SELECT DISTINCT
484
+ ```
485
+
486
+ Aggregates, `groupBy()` and `distinct()` don't combine with related tables in the
487
+ same select. For `HAVING`, use `.having(db.raw('count(*) >= ?', [2]))` (see
488
+ [Raw SQL fragments](#raw-sql-fragments)).
489
+
490
+ ---
491
+
492
+ ## Insert, update, delete
493
+
494
+ Writes follow Postgres: every matching row is written. Add `.returning()` to get
495
+ the written rows back, like SQL `RETURNING`.
496
+
497
+ ```ts
498
+ await db.from('users').insert({ name, email }) // data: null, count: 1
499
+ await db.from('users').insert({ name, email }).returning() // data: [{ id, name, email, ... }]
500
+ await db.from('users').insert([row1, row2]).returning('id') // data: [{ id }, { id }]
501
+
502
+ await db.from('users').update({ name: 'New' }).eq('active', false) // count: rows changed
503
+ await db.from('users').update({ age: db.ops.increment() }).eq('id', id).returning('age') // [{ age: 31 }]
504
+
505
+ await db.from('posts').delete().eq('user_id', id).returning('id') // every deleted row
506
+ ```
507
+
508
+ - `returning()` is **always an array**: one row is `[row]`, none is `[]`. It takes `'*'`
509
+ (the default) or column names; related tables are read with a normal `select()` afterwards.
510
+ - Without `returning()`, `data` is `null` and `count` is the number of rows written
511
+ (Postgres' `INSERT 0 2` / `UPDATE 3` / `DELETE 1`).
512
+ - `update()` and `delete()` need at least one filter, so a mistake can't change the whole table.
513
+ - Writes take filters on their own table only. `select()`, `single()`, `order()`, `random()`,
514
+ `limit()` and `range()` are for reads.
515
+ - `id`, `created_at`, `updated_at` come from the database; `updated_at` changes on every update.
516
+
517
+ ---
518
+
519
+ ## Changing jsonb and arrays
520
+
521
+ Atomic: the change happens inside Postgres, so two requests at once don't overwrite each other.
522
+
523
+ | Before | Update | After |
524
+ |---|---|---|
525
+ | `[1,2,3]` | `{ scores: db.ops.append(4) }` | `[1,2,3,4]` |
526
+ | `[1,2,3]` | `{ scores: db.ops.append(4, 5) }` | `[1,2,3,4,5]` |
527
+ | `[1,2,3]` | `{ scores: db.ops.append([4, 5]) }` | `[1,2,3,[4,5]]` (one element) |
528
+ | `[1,2,3]` | `{ scores: db.ops.prepend(0) }` | `[0,1,2,3]` |
529
+ | `[1,2,3]` | `{ scores: db.ops.remove(2) }` | `[1,3]` (every 2 removed) |
530
+ | `[1,2,3]` | `{ scores: db.ops.set(9).at(0) }` | `[9,2,3]` |
531
+ | `[1,2,3]` | `{ scores: db.ops.unset().at(0) }` | `[2,3]` |
532
+ | `[1,2,3]` | `{ scores: [7, 8] }` | `[7,8]` (full replace) |
533
+ | `{a:1}` | `{ meta: db.ops.merge({ b: 2 }) }` | `{a:1,b:2}` |
534
+ | `{a:1}` | `{ meta: { b: 2 } }` | `{b:2}` (full replace) |
535
+ | `{tags:['x']}` | `{ meta: db.ops.append('y').at('tags') }` | `{tags:['x','y']}` |
536
+ | `{n:1}` | `{ meta: db.ops.increment(5).at('n') }` | `{n:6}` |
537
+ | `{a:1,b:2}` | `{ meta: db.ops.unset().at('b') }` | `{a:1}` |
538
+
539
+ Also:
540
+ - **Postgres arrays** (`text[]`): `db.ops.append`, `db.ops.prepend`, `db.ops.remove`.
541
+ - **Numbers**: `{ age: db.ops.increment() }`, `db.ops.increment(-1)`.
542
+ - **Combining:** `update({ name: 'x', age: db.ops.increment(), scores: db.ops.append(4) })` changes all three in one statement.
543
+
544
+ ---
545
+
546
+ ## Upsert and find-or-create
547
+
548
+ One row or many (up to 5,000 per call). `onConflict` columns need a unique constraint (default: the primary key).
549
+
550
+ ```ts
551
+ // insert, or UPDATE the existing row with these values
552
+ await db.from('users').upsert({ email, name }, { onConflict: 'email' }).returning()
553
+
554
+ // find-or-create: insert, or return the existing row UNCHANGED
555
+ const { data } = await db.from('users')
556
+ .upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true })
557
+ .returning('id')
558
+ data[0].id // returning() is always an array
559
+ ```
560
+
561
+ Safe when many requests run at once: they all get the same row, with no duplicates and no errors.
562
+ Unlike Supabase, `ignoreDuplicates` returns the existing row, so transaction steps can use it.
563
+
564
+ ### Choosing what changes: `merge`
565
+
566
+ Without `merge`, an existing row gets every column you send (except the conflict key).
567
+ With `merge`, only the listed columns change and the rest keep their stored value:
568
+
569
+ | `merge` value | Existing row's column becomes |
570
+ |---|---|
571
+ | `'set'` | the incoming value |
572
+ | `'add'` | stored + incoming (NULL counts as 0) |
573
+ | `'append'` / `'prepend'` | stored and incoming jsonb or Postgres arrays joined |
574
+ | `db.ops.increment(1)`, `db.ops.merge({...})`, ... | the operator applied to the stored value |
575
+
576
+ ```ts
577
+ // counters per post, 2,000 rows at a time
578
+ await db.from('posts_stats').upsert(batch, {
579
+ onConflict: 'post_id',
580
+ merge: { views: 'add', likes: 'add', last_viewer_email: 'set', last_viewer_id: 'set' },
581
+ })
582
+
583
+ // a new row is inserted as sent; an existing one gets name set and age + 1
584
+ await db.from('users').upsert({ email, name, age: 0 }, { onConflict: 'email', merge: { name: 'set', age: db.ops.increment(1) } })
585
+
586
+ merge: ['name', 'email'] // shorthand: 'set' for each
587
+ ```
588
+
589
+ - Each conflict key may appear once per call; two rows with the same key are an error
590
+ (`upsert: rows 0 and 2 have the same email ["a"]; ...`). Combine them first.
591
+ - A column with `'set'`, `'add'`, `'append'` or `'prepend'` must be in the rows; an
592
+ operator doesn't need to be.
593
+ - For "set this only when ...", add an `update()` step with that filter in the same
594
+ transaction:
595
+
596
+ ```ts
597
+ await db.transaction((tx) => [
598
+ tx.from('clients').upsert(row, { onConflict: 'user_name', merge: { subject: 'set', total_code_leads: tx.ops.increment(1) } }),
599
+ tx.from('clients').update({ total_sent: 100 }).eq('user_name', row.user_name).not('shared_email', 'is', null),
600
+ ])
601
+ ```
602
+
603
+ ---
604
+
605
+ ## Writing related rows
606
+
607
+ Put `rel.*` under the related table's name (with `!hint` when needed). The whole
608
+ `insert()`/`update()` runs in one transaction: if anything fails, nothing is saved.
609
+
610
+ ```ts
611
+ await db.from('posts').insert({
612
+ title: 'Hello',
613
+ user_id: userId,
614
+ comments: db.rel.create([{ body: 'first', author_id: userId }]), // post_id filled in for you
615
+ labels: db.rel.connect([labelId]), // adds the post_labels row
616
+ }).returning('id')
617
+
618
+ await db.from('posts').update({
619
+ title: 'New title',
620
+ labels: db.rel.set([labelA, labelB]), // exactly these labels
621
+ comments: db.rel.update({ id: commentId }, { body: 'edited' }),
622
+ }).eq('id', postId)
623
+ ```
624
+
625
+ | | one-to-many (`comments`) | many-to-many (`labels`) | many-to-one (`'users!user_id'`) |
626
+ |---|---|---|---|
627
+ | `db.rel.create(rows)` | add children | create + link | create, set the FK |
628
+ | `db.rel.upsert(row, { onConflict })` | add or update a child | upsert + link | upsert, set the FK |
629
+ | `db.rel.connectOrCreate(row, { onConflict })` | find or create, move here | find or create + link | find or create, set the FK |
630
+ | `db.rel.connect(keys)` | move children here | link | set the FK |
631
+ | `db.rel.disconnect(keys)` | set child FK to null | unlink | set the FK to null |
632
+ | `db.rel.set(keys)` | exactly these children | exactly these links | — |
633
+ | `db.rel.update(match, values)` | update matching children | update matching linked rows | — |
634
+ | `db.rel.delete(match?)` | delete matching children | delete matching linked rows | — |
635
+
636
+ Created rows can contain `rel.*` themselves (any depth). `connect` with a key that
637
+ doesn't exist fails and rolls everything back.
638
+
639
+ ---
640
+
641
+ ## Transactions
642
+
643
+ Several steps, one transaction: all are saved, or none.
644
+
645
+ **Named steps (an object).** The keys name the steps and become the keys of `data`:
646
+
647
+ ```ts
648
+ const { data, error } = await db.transaction((tx) => ({
649
+ user: tx.from('users').upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true }).returning('id'),
650
+ post: tx.from('posts').upsert({ user_id: tx.ref('user.id'), title }, { onConflict: 'user_id,title', ignoreDuplicates: true }).returning('id'),
651
+ link: tx.from('post_labels').upsert({ post_id: tx.ref('post.id'), label_id }, { ignoreDuplicates: true }),
652
+ total: tx.from('posts').eq('user_id', tx.ref('user.id')).count(),
653
+ }))
654
+ data.user[0].id // found or created
655
+ data.link // null (no returning())
656
+ data.total // a number
657
+ ```
658
+
659
+ **Array form,** naming steps with `.as()`; `data` is an array in the same order:
660
+
661
+ ```ts
662
+ await db.transaction((tx) => [
663
+ tx.from('posts').insert({ title, user_id }).returning('id').as('post'),
664
+ tx.from('comments').update({ body: 'x' }).eq('post_id', tx.ref('post.id')),
665
+ ])
666
+ ```
667
+
668
+ Rules:
669
+ - Steps run top to bottom; `tx.ref('name.column')` reads an earlier step's row, in values or filters.
670
+ A step that returned one row (or `.single()`) is read as `name.column`; with several rows,
671
+ pick one: `tx.ref('posts.0.id')`.
672
+ - Any step can be select, insert, upsert, update, delete or count, with `ops.*` and `rel.*`.
673
+ - Return the steps; don't `await` them inside the callback.
674
+ - Step names are words without dots. Number-like keys (`'1'`) are rejected because JavaScript reorders them.
675
+ - On failure: `error.step` (0-based) and `error.stepName` say which step failed; every step is rolled back.
676
+
677
+ ---
678
+
679
+ ## Read replicas
680
+
681
+ When the API server has read replicas (`REPLICA_URLS`), reads can use them.
682
+ Reads use the primary unless you ask: a replica can be a moment behind, so a
683
+ row you just wrote may not be there yet.
684
+
685
+ ```ts
686
+ // one query
687
+ await db.from('posts').select('title').eq('published', true).replica()
688
+
689
+ // every read by default; primary() for a read that must see your own write
690
+ const db = createClient(url, key, { readFrom: 'replica' })
691
+ await db.from('users').insert({ name, email })
692
+ await db.from('users').select().eq('email', email).primary()
693
+ ```
694
+
695
+ - Only reads (`select()`, `count()`) use a replica. Writes and every step of
696
+ `transaction()` run on the primary; `.replica()` on them is an error.
697
+ - Without a healthy replica (none configured, down, or behind by more than
698
+ `REPLICA_MAX_LAG`) the read runs on the primary instead, with no error. A read
699
+ that fails because the replica went away is retried on the primary.
700
+ - With several replicas, each read goes to the one with the fewest busy connections.
701
+ - The `X-Served-By` response header says who answered: `primary` or `replica-1`, `replica-2`, ...
702
+
703
+ ---
704
+
705
+ ## Raw SQL fragments
706
+
707
+ When the builder has no method for it, `db.raw(sql, bindings?)` adds one SQL
708
+ expression to the query (same as `import { raw }`, and `tx.raw` in
709
+ transactions). It is knex's `whereRaw` / `orderByRaw` / `havingRaw` /
710
+ `knex.raw`: a piece of a query, never a whole statement. `test/raw.test.ts`
711
+ checks these against the test database.
712
+
713
+ ```ts
714
+ const n = db.raw('substring(name from 6)::int') // user_42 -> 42
715
+
716
+ // where(): AND (<fragment>)
717
+ await db.from('users').select('name').where(db.raw('substring(name from 6)::int <= ?', [3])).order('name')
718
+ // [{ name: 'user_1' }, { name: 'user_2' }, { name: 'user_3' }]
719
+
720
+ // inside or() / and() / not() it is one condition of the group
721
+ await db.from('users').select('name')
722
+ .or((q) => q.where(db.raw('substring(name from 6)::int = ?', [1])).eq('name', 'user_2'))
723
+ // user_1, user_2
724
+
725
+ // array binding: = any(?)
726
+ await db.from('users').select('name').where(db.raw('lower(trim(email)) = any(?)', [['user_1@example.com', 'user_7@example.com']]))
727
+
728
+ // jsonb ? operator: \? (written '\\?' in JS) is a literal ?
729
+ await db.from('users').where(db.raw('meta \\? ?', ['vip'])).count() // 100
730
+
731
+ // order()
732
+ await db.from('users').select('name').order(n, { ascending: false }).limit(3)
733
+ // user_100, user_99, user_98
734
+
735
+ // select(): extra arguments are raw columns, each needs .as(name); options go last
736
+ await db.from('users').select('name', db.raw('char_length(name)').as('name_len')).eq('name', 'user_42').single()
737
+ // { name: 'user_42', name_len: 7 }
738
+
739
+ // having(), with groupBy()
740
+ await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
741
+ // 33 groups, each n: 2
742
+ ```
743
+
744
+ As values in `insert()` / `update()` / `upsert()` rows, and in upsert `merge`,
745
+ where `excluded.col` is the incoming value and `table.col` the stored one:
746
+
747
+ ```ts
748
+ await db.from('users').update({ age: db.raw('age + ?', [5]) }).eq('email', email).returning('age') // 10 -> [{ age: 15 }]
749
+
750
+ await db.from('users').upsert({ email, name: 'second', age: 5 }, {
751
+ onConflict: 'email',
752
+ merge: { age: db.raw('CASE WHEN excluded.age IS NOT NULL AND users.age IS NOT NULL THEN users.age + excluded.age ELSE ? END', [100]) },
753
+ })
754
+ // stored age 10 -> 15; name stays (only merge columns change); with age: null -> 100
755
+ ```
756
+
757
+ Rules:
758
+
759
+ - Each `?` is one binding, passed as a query parameter, so values are never
760
+ pasted into the SQL. The count must match `bindings`, else a 400 error
761
+ (`raw fragment: 2 placeholders but 1 binding`). Strings, numbers, booleans and
762
+ `null` pass through; an array becomes a Postgres array (cast it if needed:
763
+ `?::text[]`); an object becomes jsonb (`?::jsonb`). A `?` inside `'quoted
764
+ text'` is not a binding.
765
+ - `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator.
766
+ - Fragments see the main table only: its columns, plain or qualified with the
767
+ table's name (`users.age`, `"users".age`), and `excluded.col` in `merge`.
768
+ Related tables are not visible, and `order(raw)` with `referencedTable` is an error.
769
+ - The API key must be allowed to use raw fragments. Otherwise every request
770
+ (or transaction) that contains one gets 403, code `RAW_NOT_ALLOWED`.
771
+ - The server rejects with 400, code `RAW_FRAGMENT`: `;`, `--` and `/*`
772
+ comments, dollar quoting (`$$`), the keywords `select`, `insert`, `update`,
773
+ `delete`, `with`, `union`, `into`, `copy`, `grant`, `drop`, `alter`,
774
+ `create`, `truncate`, `execute`, `do`, `call` outside quoted text, an empty
775
+ fragment or one over 2,000 characters, and more than 100 bindings.
776
+ Functions, `CASE WHEN`, casts, `interval '1 hour'`, operators, `count(*)`
777
+ and `excluded.x` are fine.
778
+ - In the client, `raw()` throws when `sql` is not a non-empty string or
779
+ `bindings` not an array. `select()` with a raw column lacking `.as()`, and
780
+ `where()` / `having()` with something other than `raw()`, resolve to `{ error }`
781
+ without sending.
782
+
783
+ Moving from knex:
784
+
785
+ | knex | arn-db |
786
+ |---|---|
787
+ | `.whereRaw(sql, b)`, `.andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
788
+ | `.orWhereRaw(sql, b)` | `.or((q) => q.eq(...).where(db.raw(sql, b)))` |
789
+ | `.orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending, nullsFirst })` |
790
+ | `.select('id', knex.raw('char_length(title) as n'))` | `.select('id', db.raw('char_length(title)').as('n'))` |
791
+ | `{ total: knex.raw('total + ?', [1]) }` | `{ total: db.raw('total + ?', [1]) }` |
792
+ | `.onConflict('email').merge({ total: knex.raw('…') })` | `.upsert(rows, { onConflict: 'email', merge: { total: db.raw('…') } })` |
793
+ | `.havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
794
+
795
+ ---
796
+
797
+ ## Errors
798
+
799
+ `error` has `message`, and usually `code` and `details`:
800
+
801
+ | `code` | Meaning | Example |
802
+ |---|---|---|
803
+ | `23505` | duplicate value in a unique column | email already used |
804
+ | `23503` | foreign key: the referenced row doesn't exist | `db.rel.connect(unknownId)` |
805
+ | `23502` | a required (NOT NULL) column is missing | insert without `body` |
806
+ | `22P02` | value has the wrong format | `.eq('id', 'abc')` on a uuid |
807
+ | `42703` | column doesn't exist | `select('nope')` → `column _t0.nope does not exist` (`_t0` is the main table) |
808
+ | `42P10` | `onConflict` columns have no unique constraint | `upsert(…, { onConflict: 'title' })` |
809
+ | `RAW_FRAGMENT` | (400) a `db.raw()` fragment the server refuses | `db.raw('1; drop table x')` |
810
+ | `RAW_NOT_ALLOWED` | (403) the API key may not use raw fragments | |
811
+ | `401` | missing or wrong API key | |
812
+ | `403` | request came from a browser | |
813
+
814
+ Only setup mistakes throw: `createClient()` without a key, `db.raw()` without a
815
+ SQL string, and `db`'s first query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
816
+
817
+ Errors without a code are checks done before the database runs anything, for
818
+ example: `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
819
+
820
+ ---
821
+
822
+ ## Not supported yet
823
+
824
+ - Sorting main rows by a related table's column (e.g. posts by author name). Sorting *inside* a relation works.
825
+ - OR across tables in one group (e.g. `name = 'x' OR posts.published`): each `or()` group is on one table.
826
+ - Full-text search and calling Postgres functions (`rpc`) as methods. Column
827
+ functions like `lower(trim(email))` and `having` work through
828
+ [raw fragments](#raw-sql-fragments).
829
+ - `db.col()` across tables, and as a value in `insert()` / `update()`.