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 +829 -0
- package/README.md +265 -2
- package/dist/index.cjs +1112 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +899 -0
- package/dist/index.d.mts +899 -0
- package/dist/index.mjs +1098 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +48 -4
package/README.md
CHANGED
|
@@ -1,3 +1,266 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arn-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Supabase-style query builder for Node.js. It talks to a Go query API
|
|
4
|
+
(`/v2/raw`), which talks to Postgres. It replaces arn-knexjs.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
Node app (arn-db) ──HTTP/JSON──▶ your-api.example.com/v2/raw (Go) ──▶ Postgres
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Server-side only: every request is a POST with an `apikey` header, and the API
|
|
11
|
+
refuses browser requests (any request with an `Origin` header) and sends no CORS
|
|
12
|
+
headers. Each key may be limited to a list of tables.
|
|
13
|
+
|
|
14
|
+
**New here? Read [GUIDE.md](GUIDE.md):** recipes for selecting, relations,
|
|
15
|
+
searching across tables, ordering/random, jsonb changes, counts, upsert and
|
|
16
|
+
transactions, each with its real output.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install arn-db
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Set the API address and the app's key (in `.env` or the container env):
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
ARN_DB_URL=https://your-api.example.com/v2/raw
|
|
28
|
+
ARN_DB_KEY=<your API key>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
import { db } from 'arn-db' // or: const { db } = require('arn-db')
|
|
33
|
+
|
|
34
|
+
const { data, error } = await db.from('users').select().eq('id', 1).single()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's all: `db` reads the two variables on its first query. If they aren't set,
|
|
38
|
+
it loads `./.env` first (Node 20.12+), as arn-knexjs did with dotenv. ESM, CommonJS and
|
|
39
|
+
TypeScript types are included.
|
|
40
|
+
|
|
41
|
+
## Quick tour
|
|
42
|
+
|
|
43
|
+
Import the ready `db` everywhere: `db.from()`, `db.transaction()`, `db.ops.*`,
|
|
44
|
+
`db.rel.*`, `db.raw()`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
|
|
45
|
+
`tx.rel.*`, `tx.raw()`, `tx.ref()`). For another key or options, make your own client with
|
|
46
|
+
`createClient(url, key, options)`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { db } from 'arn-db'
|
|
50
|
+
|
|
51
|
+
// all columns: select() or select('*'); with related rows: select('*, posts!user_id(*)')
|
|
52
|
+
const { data, error } = await db.from('users').select()
|
|
53
|
+
|
|
54
|
+
// filters, several orders, random (after other orders it only shuffles ties)
|
|
55
|
+
await db.from('users').select('id, name').eq('active', true)
|
|
56
|
+
.order('vip', { ascending: false }).order('age', { ascending: false }).random().limit(5)
|
|
57
|
+
|
|
58
|
+
// stable random pages: same seed, same order, no repeats across range() pages
|
|
59
|
+
await db.from('posts').select('*').random({ seed: sessionId }).range(10, 19)
|
|
60
|
+
|
|
61
|
+
// one row -> object or null; limit(1) -> always an array
|
|
62
|
+
await db.from('users').select().eq('id', id).single() // { ... } or null
|
|
63
|
+
await db.from('users').select().limit(1) // [{ ... }] or []
|
|
64
|
+
|
|
65
|
+
// writes: returning() is always an array, like Postgres RETURNING ('*' or columns)
|
|
66
|
+
await db.from('users').insert({ name, email }).returning('id') // [{ id }]
|
|
67
|
+
await db.from('users').update({ active: false }).eq('age', 30) // data: null, count: rows changed
|
|
68
|
+
|
|
69
|
+
// OR / NOT, nested as deep as you need (chained filters are AND)
|
|
70
|
+
await db.from('users').select('name').or((q) => q.gt('age', 60).and((q) => q.eq('active', false).lt('age', 20)))
|
|
71
|
+
await db.from('users').select('name').not('meta->>city', 'eq', 'Dhaka')
|
|
72
|
+
|
|
73
|
+
// JSON paths, casts, contains
|
|
74
|
+
await db.from('users').select('name, city:meta->>city').gte('meta->>score::int', 90)
|
|
75
|
+
await db.from('users').select('*').contains('meta', { vip: true })
|
|
76
|
+
|
|
77
|
+
// relations: key = table name, or the alias you give it
|
|
78
|
+
await db.from('posts').select('title, author:users!user_id(name), comments(body), labels(name)')
|
|
79
|
+
await db.from('users').select('name, posts!user_id!inner(title)').eq('posts.published', false)
|
|
80
|
+
await db.from('labels').select('name, posts(title)').random({ referencedTable: 'posts' }).limit(3, { referencedTable: 'posts' })
|
|
81
|
+
|
|
82
|
+
// read replica (when the server has one; otherwise the primary answers)
|
|
83
|
+
await db.from('posts').select('title').replica()
|
|
84
|
+
const reads = createClient(url, key, { readFrom: 'replica' }) // import { createClient }; all reads; .primary() to opt out
|
|
85
|
+
|
|
86
|
+
// atomic updates
|
|
87
|
+
await db.from('users').update({ scores: db.ops.append(4), age: db.ops.increment() }).eq('id', id)
|
|
88
|
+
await db.from('users').update({ meta: db.ops.append('x').at('address', 'lines') }).eq('id', id)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Errors never throw: every call (and every transaction) resolves to
|
|
92
|
+
`{ data, error, count, status }`, like Supabase. Only setup mistakes throw:
|
|
93
|
+
`createClient()` without an API key or fetch, `db.raw()` without a SQL string,
|
|
94
|
+
and `db`'s first query when `ARN_DB_URL` / `ARN_DB_KEY` is missing.
|
|
95
|
+
|
|
96
|
+
## Searching arrays (jsonb arrays and Postgres arrays)
|
|
97
|
+
|
|
98
|
+
For a column holding `["one", "two"]`:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
.overlaps('tags', ['one', 'three']) // has ANY of them -> matches
|
|
102
|
+
.contains('tags', ['one', 'two']) // has ALL of them -> matches
|
|
103
|
+
.containedBy('tags', ['one', 'two', 'three']) // has NOTHING else -> matches
|
|
104
|
+
.eq('tags', ['one', 'two']) // exactly, same order -> matches
|
|
105
|
+
.overlaps('meta->tags', ['red']) // array inside a jsonb object
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
These work on `jsonb` / `json` arrays (any element type, including objects) and on
|
|
109
|
+
Postgres arrays like `text[]`. A value that is not an array simply doesn't match.
|
|
110
|
+
|
|
111
|
+
## Counts
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const { data: total } = await db.from('users').count() // 100
|
|
115
|
+
const { data: active } = await db.from('users').eq('active', true).count() // 75
|
|
116
|
+
|
|
117
|
+
// Supabase form: count without rows (data is null)
|
|
118
|
+
const { count } = await db.from('users').select('*', { count: 'exact', head: true }).eq('active', false)
|
|
119
|
+
|
|
120
|
+
// rows + total, for "page 1 of N"
|
|
121
|
+
const { data, count: all } = await db.from('posts').select('*', { count: 'exact' }).range(0, 9)
|
|
122
|
+
|
|
123
|
+
// per related row (Supabase shape)
|
|
124
|
+
await db.from('users').select('name, posts!user_id(count)')
|
|
125
|
+
// [{ name: 'user_5', posts: [{ count: 2 }] }, ...] many-to-one gives { count: n }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Counts work in transactions too: `{ n: tx.from('posts').eq('user_id', tx.ref('user.id')).count() }`.
|
|
129
|
+
|
|
130
|
+
## Upsert
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
// insert, or update the existing row with the same email
|
|
134
|
+
await db.from('users').upsert({ email, name }, { onConflict: 'email' }).returning()
|
|
135
|
+
|
|
136
|
+
// find-or-create: insert, or return the existing row unchanged
|
|
137
|
+
const { data: user } = await db.from('users')
|
|
138
|
+
.upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true })
|
|
139
|
+
.returning('id')
|
|
140
|
+
user[0].id // returning() is always an array
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `onConflict` columns need a unique constraint (default: the primary key).
|
|
144
|
+
- `ignoreDuplicates: true` still returns the existing row (Supabase returns
|
|
145
|
+
nothing there), so a transaction step can `tx.ref()` it.
|
|
146
|
+
- Safe under concurrency: many requests creating the same row at once all get
|
|
147
|
+
the same row, with no duplicate and no error.
|
|
148
|
+
- One row or many (up to 5,000). `merge: { total: 'add', email: 'set', hits: ops.increment(1) }`
|
|
149
|
+
changes only those columns of an existing row; see GUIDE.md.
|
|
150
|
+
|
|
151
|
+
## Writes across related tables (all or nothing)
|
|
152
|
+
|
|
153
|
+
Put `rel.*` under the related table's name (add `!fk_column` when the table has
|
|
154
|
+
several FKs to it, e.g. `'users!user_id'`). The whole `insert()`/`update()` runs
|
|
155
|
+
in one database transaction: if any part fails, nothing is saved.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
await db.from('posts').insert({
|
|
159
|
+
title: 'Hello',
|
|
160
|
+
user_id: userId,
|
|
161
|
+
comments: db.rel.create([{ body: 'first', author_id: userId }]), // post_id filled in
|
|
162
|
+
labels: db.rel.connect([labelId]), // post_labels row added
|
|
163
|
+
}).select('*, comments(*), labels(*)')
|
|
164
|
+
|
|
165
|
+
await db.from('posts').update({
|
|
166
|
+
title: 'New title',
|
|
167
|
+
labels: db.rel.set([labelA, labelB]), // exactly these links
|
|
168
|
+
comments: db.rel.update({ id: commentId }, { body: 'edited' }),
|
|
169
|
+
}).eq('id', postId)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| | one-to-many (`posts → comments`) | many-to-many (`posts ↔ labels`) | many-to-one (`posts → users!user_id`) |
|
|
173
|
+
|---|---|---|---|
|
|
174
|
+
| `db.rel.create(rows)` | insert children | insert targets + link | insert target first, set the FK |
|
|
175
|
+
| `db.rel.upsert(row, { onConflict })` | insert or update a child | upsert target + link | upsert target, set the FK |
|
|
176
|
+
| `db.rel.connectOrCreate(row, { onConflict })` | find or create a child, move it here | find or create target + link | find or create target, set the FK |
|
|
177
|
+
| `db.rel.connect(keys)` | move children here | link | set the FK |
|
|
178
|
+
| `db.rel.disconnect(keys)` | set child FK to null | unlink | set the FK to null |
|
|
179
|
+
| `db.rel.set(keys)` | exactly these children | exactly these links | — |
|
|
180
|
+
| `db.rel.update(match, values)` | update matching children | update matching linked rows | — |
|
|
181
|
+
| `db.rel.delete(match?)` | delete matching children | delete matching linked rows | — |
|
|
182
|
+
|
|
183
|
+
Created rows may contain `rel.*` themselves (any depth). `connect` fails if a key
|
|
184
|
+
does not exist. `returning()` on a nested write needs a single-column primary key.
|
|
185
|
+
|
|
186
|
+
## Transactions with several steps
|
|
187
|
+
|
|
188
|
+
Return an object: keys name the steps, run top to bottom, and `data` has the
|
|
189
|
+
same keys (typed per step).
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const { data, error } = await db.transaction((tx) => ({
|
|
193
|
+
user: tx.from('users').upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true }).returning('id'),
|
|
194
|
+
post: tx.from('posts').upsert({ user_id: tx.ref('user.id'), title }, { onConflict: 'user_id,title', ignoreDuplicates: true }).returning('id'),
|
|
195
|
+
link: tx.from('post_labels').upsert({ post_id: tx.ref('post.id'), label_id }, { ignoreDuplicates: true }),
|
|
196
|
+
}))
|
|
197
|
+
data.user[0].id // found or created
|
|
198
|
+
data.link // null: no returning()
|
|
199
|
+
error?.stepName // which step failed; everything is rolled back
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Or return an array and name steps with `.as()`; `data` is then an array.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
await db.transaction((tx) => [
|
|
206
|
+
tx.from('posts').insert({ title, user_id }).returning('id').as('post'),
|
|
207
|
+
tx.from('post_labels').insert({ post_id: tx.ref('post.id'), label_id }),
|
|
208
|
+
])
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- Steps can be any select / insert / upsert / update / delete, with `ops.*` and `rel.*`.
|
|
212
|
+
- Don't `await` steps inside the callback; return them.
|
|
213
|
+
- `tx.ref('user.id')` reads an earlier step's row (in values or filters). If it
|
|
214
|
+
returned an array, pick one: `tx.ref('posts.0.id')`.
|
|
215
|
+
- Step names: words without dots. Number-like keys (`'1'`) are rejected because
|
|
216
|
+
JavaScript would reorder them.
|
|
217
|
+
|
|
218
|
+
## Raw SQL fragments
|
|
219
|
+
|
|
220
|
+
For the bits the builder has no method for, `db.raw(sql, bindings?)` (also
|
|
221
|
+
`import { raw }` and `tx.raw`) adds one SQL expression to the query the builder
|
|
222
|
+
makes, like knex's `whereRaw` / `orderByRaw` / `knex.raw`. It is a fragment,
|
|
223
|
+
never a whole statement.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// filters: AND (<fragment>), or one member of an or() group
|
|
227
|
+
await db.from('users').select('id').where(db.raw('lower(trim(email)) = any(?)', [emails]))
|
|
228
|
+
await db.from('accounts').select().or((q) => q.where(db.raw('sent_per_hour < limit_per_hour')).is('limit_per_hour', null))
|
|
229
|
+
|
|
230
|
+
// order, computed select columns (.as() is required), having
|
|
231
|
+
await db.from('accounts').select().order(db.raw("coalesce(last_read_time, '1970-01-01')"), { ascending: true })
|
|
232
|
+
await db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
|
|
233
|
+
await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
|
|
234
|
+
|
|
235
|
+
// values in insert / update / upsert rows, and in upsert merge (excluded.col = incoming value)
|
|
236
|
+
await db.from('accounts').update({ sent_total: db.raw('sent_total + ?', [1]) }).eq('id', id)
|
|
237
|
+
await db.from('accounts').upsert(rows, {
|
|
238
|
+
onConflict: 'email',
|
|
239
|
+
merge: { total: db.raw('CASE WHEN excluded.total IS NULL THEN accounts.total ELSE excluded.total END') },
|
|
240
|
+
})
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- Each `?` is a binding, sent as a query parameter (never pasted into the SQL).
|
|
244
|
+
Their number must match `bindings`. An array binding is a Postgres array
|
|
245
|
+
(`= any(?)`), an object is jsonb (`?::jsonb`). `?` inside `'quoted text'` is
|
|
246
|
+
not a binding.
|
|
247
|
+
- `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator:
|
|
248
|
+
`db.raw('meta \\? ?', ['vip'])`.
|
|
249
|
+
- A fragment sees the main table's columns, plain or qualified with the table
|
|
250
|
+
name (`accounts.total`); not related tables.
|
|
251
|
+
- The API key must be allowed to use raw fragments; otherwise the request gets
|
|
252
|
+
403 `RAW_NOT_ALLOWED`.
|
|
253
|
+
- The server rejects (400 `RAW_FRAGMENT`): `;`, `--` and `/* */` comments,
|
|
254
|
+
dollar quoting, statement keywords (`select`, `insert`, `update`, `delete`,
|
|
255
|
+
`with`, `union`, `into`, `drop`, `alter`, `create`, `truncate`, …) outside
|
|
256
|
+
quoted text, fragments over 2,000 characters and more than 100 bindings.
|
|
257
|
+
|
|
258
|
+
| knex | arn-db |
|
|
259
|
+
|---|---|
|
|
260
|
+
| `whereRaw(sql, b)` / `andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
|
|
261
|
+
| `orWhereRaw(sql, b)` | `.or((q) => q.where(db.raw(sql, b)).eq(...))` |
|
|
262
|
+
| `orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending })` |
|
|
263
|
+
| `select('id', knex.raw('… as x'))` | `.select('id', db.raw('…').as('x'))` |
|
|
264
|
+
| `{ col: knex.raw('col + ?', [1]) }` value | `{ col: db.raw('col + ?', [1]) }` |
|
|
265
|
+
| `merge({ col: knex.raw(...) })` | `upsert(rows, { merge: { col: db.raw(...) } })` |
|
|
266
|
+
| `havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
|