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/README.md CHANGED
@@ -1,3 +1,266 @@
1
- # Temporary Holding Version
1
+ # arn-db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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))` |