arn-db 0.0.0-stage → 0.1.0

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