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/dist/index.mjs ADDED
@@ -0,0 +1,1098 @@
1
+ // src/rel.ts
2
+ const list = (v) => Array.isArray(v) ? v : [v];
3
+ /** "user_id, title" -> { onConflict: ['user_id', 'title'] } */
4
+ const conflict = (columns) => columns ? { onConflict: columns.split(",").map((c) => c.trim()).filter(Boolean) } : {};
5
+ const rel = {
6
+ /**
7
+ * Insert related rows (they may contain `rel.*` themselves).
8
+ * @example
9
+ * db.from('posts').insert({ title, user_id, comments: rel.create([{ body, author_id }]) })
10
+ */
11
+ create: (rows) => ({
12
+ $rel: "create",
13
+ rows: list(rows)
14
+ }),
15
+ /**
16
+ * Insert one related row, or update it if its `onConflict` key (default: primary key) exists.
17
+ * @example
18
+ * db.from('posts').update({ comments: rel.upsert({ id: commentId, body: 'v2', author_id }) }).eq('id', postId)
19
+ */
20
+ upsert: (row, options = {}) => ({
21
+ $rel: "upsert",
22
+ rows: [row],
23
+ ...conflict(options.onConflict)
24
+ }),
25
+ /**
26
+ * Find one related row by its `onConflict` key (default: primary key), or create it,
27
+ * then link it. An existing row is not changed.
28
+ * @example
29
+ * db.from('posts').insert({ title, user_id, labels: rel.connectOrCreate({ name: 'go' }, { onConflict: 'name' }) })
30
+ */
31
+ connectOrCreate: (row, options = {}) => ({
32
+ $rel: "connectOrCreate",
33
+ rows: [row],
34
+ ...conflict(options.onConflict)
35
+ }),
36
+ /**
37
+ * Link existing rows by key. Fails (and rolls back) if a key does not exist.
38
+ * @example
39
+ * db.from('posts').update({ labels: rel.connect([labelA, labelB]) }).eq('id', postId)
40
+ */
41
+ connect: (keys) => ({
42
+ $rel: "connect",
43
+ keys: list(keys)
44
+ }),
45
+ /**
46
+ * Unlink rows by key (many-to-one: clears the FK; one-to-many: sets the child FK to null).
47
+ * @example
48
+ * db.from('posts').update({ labels: rel.disconnect(labelId) }).eq('id', postId)
49
+ */
50
+ disconnect: (keys = []) => ({
51
+ $rel: "disconnect",
52
+ keys: list(keys)
53
+ }),
54
+ /**
55
+ * Make exactly these rows linked: unlinks the others, links the missing ones.
56
+ * @example
57
+ * db.from('posts').update({ labels: rel.set([labelA]) }).eq('id', postId) // rel.set([]) unlinks all
58
+ */
59
+ set: (keys) => ({
60
+ $rel: "set",
61
+ keys
62
+ }),
63
+ /**
64
+ * Update linked rows that match (equality on every key of `match`).
65
+ * @example
66
+ * db.from('posts').update({ comments: rel.update({ id: commentId }, { body: 'edited' }) }).eq('id', postId)
67
+ */
68
+ update: (match, values) => ({
69
+ $rel: "update",
70
+ match,
71
+ values
72
+ }),
73
+ /**
74
+ * Delete linked rows that match; `rel.delete()` deletes all linked rows.
75
+ * @example
76
+ * db.from('posts').update({ comments: rel.delete({ body: 'spam' }) }).eq('id', postId)
77
+ */
78
+ delete: (match = {}) => ({
79
+ $rel: "delete",
80
+ match
81
+ })
82
+ };
83
+ // src/values.ts
84
+ /**
85
+ * Values the database fills in: another column of the same row (`db.col`)
86
+ * and the database's current time (`db.now`).
87
+ */
88
+ /**
89
+ * Another column of the same row. As a filter value it compares two columns;
90
+ * as the column of a filter it lets `ifNull()` replace NULL first.
91
+ *
92
+ * .lt('sent_per_hour', db.col('limit_per_hour'))
93
+ * .lt(db.col('sent_per_hour').ifNull(0), db.col('limit_per_hour'))
94
+ */
95
+ var Col = class Col {
96
+ name;
97
+ #ifNull;
98
+ constructor(name, ifNull) {
99
+ this.name = name;
100
+ this.#ifNull = ifNull;
101
+ }
102
+ /** Use `value` where the column is NULL: `COALESCE(column, value)`. */
103
+ ifNull(value) {
104
+ return new Col(this.name, { value });
105
+ }
106
+ /** @internal The NULL replacement, if any. */
107
+ nullValue() {
108
+ return this.#ifNull;
109
+ }
110
+ /** @internal Wire format. */
111
+ toJSON() {
112
+ return this.#ifNull ? {
113
+ $col: this.name,
114
+ ifNull: this.#ifNull.value
115
+ } : { $col: this.name };
116
+ }
117
+ };
118
+ /**
119
+ * The database server's `now()`, optionally shifted by one interval like
120
+ * `'1 hour'` or `'30 minutes'` (units: second, minute, hour, day, week,
121
+ * month, year). Works in filters and in insert/update values.
122
+ *
123
+ * .lte('client_sent_time', db.now().minus('1 hour'))
124
+ * .update({ last_read_time: db.now() })
125
+ */
126
+ var Now = class Now {
127
+ #interval;
128
+ constructor(interval) {
129
+ this.#interval = interval;
130
+ }
131
+ /** Later by `interval`: `db.now().plus('1 day')`. */
132
+ plus(interval) {
133
+ return this.#shift(interval.trim());
134
+ }
135
+ /** Earlier by `interval`: `db.now().minus('1 hour')`. */
136
+ minus(interval) {
137
+ const i = interval.trim();
138
+ return this.#shift(i.startsWith("-") ? i.slice(1) : `-${i}`);
139
+ }
140
+ #shift(interval) {
141
+ if (this.#interval !== void 0) throw new Error("arn-db: db.now() takes one plus() or minus(); use a single interval");
142
+ return new Now(interval);
143
+ }
144
+ /** @internal Wire format. */
145
+ toJSON() {
146
+ return this.#interval === void 0 ? { $now: true } : {
147
+ $now: true,
148
+ interval: this.#interval
149
+ };
150
+ }
151
+ };
152
+ const col = (name) => new Col(name);
153
+ const now = () => new Now();
154
+ /**
155
+ * A raw SQL fragment: one expression inside the query the builder makes, with
156
+ * `?` for each binding (`\\?` is a literal `?`, e.g. the jsonb `?` operator).
157
+ * Bindings are sent as query parameters, never pasted into the SQL. Use it in
158
+ * `where()`, `order()`, `having()`, as a `select()` column with `.as()`, and as
159
+ * an insert/update/upsert value or upsert `merge` value. The API key must be
160
+ * allowed to use raw fragments.
161
+ *
162
+ * .where(db.raw('lower(email) = any(?)', [emails]))
163
+ * .update({ sent_total: db.raw('sent_total + ?', [1]) })
164
+ */
165
+ var Raw = class Raw {
166
+ sql;
167
+ bindings;
168
+ #as;
169
+ constructor(sql, bindings = [], as) {
170
+ if (typeof sql !== "string" || sql.trim() === "") throw new Error("arn-db: raw(sql) needs a non-empty SQL string");
171
+ if (!Array.isArray(bindings)) throw new Error("arn-db: raw(sql, bindings) takes the bindings as an array");
172
+ this.sql = sql;
173
+ this.bindings = bindings;
174
+ this.#as = as;
175
+ }
176
+ /** Name of this fragment as a `select()` column: `raw('char_length(title)').as('title_len')`. */
177
+ as(name) {
178
+ return new Raw(this.sql, this.bindings, name);
179
+ }
180
+ /** @internal The `.as()` name, if any. */
181
+ alias() {
182
+ return this.#as;
183
+ }
184
+ /** @internal The fragment without its name, for filters, order and having. */
185
+ fragment() {
186
+ return {
187
+ $raw: this.sql,
188
+ bindings: this.bindings
189
+ };
190
+ }
191
+ /** @internal Wire format. */
192
+ toJSON() {
193
+ return this.#as === void 0 ? this.fragment() : {
194
+ ...this.fragment(),
195
+ as: this.#as
196
+ };
197
+ }
198
+ };
199
+ /**
200
+ * A raw SQL fragment with `?` bindings: knex's `whereRaw` / `orderByRaw` /
201
+ * `havingRaw` / `knex.raw` value. Throws if `sql` is not a non-empty string
202
+ * or `bindings` not an array; the server checks the rest.
203
+ *
204
+ * @example
205
+ * db.from('users').select().where(raw('lower(trim(email)) = any(?)', [emails]))
206
+ */
207
+ const raw = (sql, bindings) => new Raw(sql, bindings);
208
+ // src/builder.ts
209
+ const embedOf = (o) => o.referencedTable ?? o.foreignTable;
210
+ /** Filter methods, shared by builders and groups. */
211
+ var Filters = class {
212
+ /** @internal A problem found while building, reported instead of sending. */
213
+ problem;
214
+ #filter(column, op, value) {
215
+ if (column instanceof Col) {
216
+ const ifNull = column.nullValue();
217
+ this.push(ifNull ? {
218
+ column: column.name,
219
+ op,
220
+ value,
221
+ ifNull: ifNull.value
222
+ } : {
223
+ column: column.name,
224
+ op,
225
+ value
226
+ });
227
+ } else this.push({
228
+ column,
229
+ op,
230
+ value
231
+ });
232
+ return this;
233
+ }
234
+ /**
235
+ * Equal. Works on columns, jsonb paths and related tables.
236
+ *
237
+ * @example
238
+ * .eq('active', true)
239
+ * .eq('meta->>city', 'Tokyo')
240
+ * .eq('posts.published', true) // filters the related posts
241
+ */
242
+ eq(column, value) {
243
+ return this.#filter(column, "eq", value);
244
+ }
245
+ /** Not equal. */
246
+ neq(column, value) {
247
+ return this.#filter(column, "neq", value);
248
+ }
249
+ /**
250
+ * Greater than. For numbers inside jsonb, cast: `.gt('meta->>score::int', 90)`.
251
+ * `eq` to `lte` also compare with another column (`db.col`) or the
252
+ * database time (`db.now`).
253
+ *
254
+ * @example
255
+ * .gt('age', 30)
256
+ * .lt('sent_per_hour', db.col('limit_per_hour'))
257
+ * .lt(db.col('sent_per_hour').ifNull(0), db.col('limit_per_hour'))
258
+ * .lte('client_sent_time', db.now().minus('1 hour'))
259
+ */
260
+ gt(column, value) {
261
+ return this.#filter(column, "gt", value);
262
+ }
263
+ /** Greater than or equal. @example .gte('meta->>score::int', 90) */
264
+ gte(column, value) {
265
+ return this.#filter(column, "gte", value);
266
+ }
267
+ /** Less than. */
268
+ lt(column, value) {
269
+ return this.#filter(column, "lt", value);
270
+ }
271
+ /** Less than or equal. */
272
+ lte(column, value) {
273
+ return this.#filter(column, "lte", value);
274
+ }
275
+ /**
276
+ * SQL LIKE: `%` is any text, `_` is one character (escape with `\\_`).
277
+ *
278
+ * @example
279
+ * .like('name', 'user\\_1%')
280
+ */
281
+ like(column, pattern) {
282
+ return this.#filter(column, "like", pattern);
283
+ }
284
+ /**
285
+ * Case-insensitive LIKE.
286
+ *
287
+ * @example
288
+ * .ilike('email', '%@example.com')
289
+ * .ilike('posts.comments.body', '%hello%') // search a deeper related table
290
+ */
291
+ ilike(column, pattern) {
292
+ return this.#filter(column, "ilike", pattern);
293
+ }
294
+ /** IS NULL / IS TRUE / IS FALSE. @example .is('deleted_at', null) */
295
+ is(column, value) {
296
+ return this.#filter(column, "is", value);
297
+ }
298
+ /** One of the values. @example .in('name', ['user_3', 'user_5']) */
299
+ in(column, values) {
300
+ return this.#filter(column, "in", values);
301
+ }
302
+ /**
303
+ * Has ALL of the values (arrays), or has these keys and values (jsonb objects).
304
+ *
305
+ * @example
306
+ * .contains('tags', ['one', 'two'])
307
+ * .contains('meta', { city: 'Dhaka', vip: true })
308
+ */
309
+ contains(column, value) {
310
+ return this.#filter(column, "cs", value);
311
+ }
312
+ /**
313
+ * Has nothing outside the values.
314
+ *
315
+ * @example
316
+ * .containedBy('tags', ['one', 'two', 'three'])
317
+ */
318
+ containedBy(column, value) {
319
+ return this.#filter(column, "cd", value);
320
+ }
321
+ /**
322
+ * Has ANY of the values. Works on jsonb/json arrays, jsonb paths and Postgres arrays.
323
+ *
324
+ * @example
325
+ * .overlaps('scores', ['one', 'three'])
326
+ * .overlaps('meta->tags', ['red', 'blue'])
327
+ */
328
+ overlaps(column, values) {
329
+ return this.#filter(column, "ov", values);
330
+ }
331
+ /** Any filter by name: `.filter('age', 'gte', 30)`. */
332
+ filter(column, op, value) {
333
+ return this.#filter(column, op, value);
334
+ }
335
+ /**
336
+ * Equal on every key.
337
+ *
338
+ * @example
339
+ * .match({ active: true, age: 30 })
340
+ */
341
+ match(values) {
342
+ for (const [k, v] of Object.entries(values)) this.eq(k, v);
343
+ return this;
344
+ }
345
+ /**
346
+ * A raw SQL condition, knex's `whereRaw`: `AND (<fragment>)`, or one member
347
+ * of an `or()` group. Only takes `raw()`; for a column use `eq()` and friends.
348
+ * The fragment sees the main table's columns, not related tables.
349
+ *
350
+ * @example
351
+ * .where(db.raw('lower(trim(email)) = any(?)', [emails]))
352
+ * .where(db.raw('sent_per_hour < limit_per_hour'))
353
+ * .or((q) => q.where(db.raw('meta \\? ?', ['vip'])).eq('age', 30)) // jsonb ? operator
354
+ */
355
+ where(fragment) {
356
+ if (fragment instanceof Raw) this.push({
357
+ op: "raw",
358
+ value: fragment.fragment()
359
+ });
360
+ else this.problem ??= "where() takes a raw() fragment; for a column use eq(), gt(), in() and friends";
361
+ return this;
362
+ }
363
+ /**
364
+ * Any of the conditions.
365
+ *
366
+ * @example
367
+ * .or((q) => q.gt('age', 30).eq('meta->>city', 'Dhaka')) // age > 30 OR city = 'Dhaka'
368
+ * .or((q) => q.eq('role', 'admin').and((q) => q.gte('age', 18).is('verified', true)))
369
+ * .or((q) => q.eq('posts.published', true).gt('posts.likes', 10)) // filters the related posts
370
+ */
371
+ or(conditions) {
372
+ return this.#group("or", conditions);
373
+ }
374
+ /**
375
+ * All of the conditions, as one group. Mostly useful inside `or()`.
376
+ *
377
+ * @example
378
+ * .or((q) => q.eq('role', 'admin').and((q) => q.gte('age', 18).eq('active', true)))
379
+ */
380
+ and(conditions) {
381
+ return this.#group("and", conditions);
382
+ }
383
+ not(a, op, value) {
384
+ if (typeof a === "function") return this.#group("not", a);
385
+ return this.#group("not", (q) => q.filter(a, op, value));
386
+ }
387
+ #group(group, conditions) {
388
+ const q = new FilterGroup();
389
+ conditions(q);
390
+ if (q.problem) this.problem ??= q.problem;
391
+ this.push({
392
+ group,
393
+ filters: q.filters
394
+ });
395
+ return this;
396
+ }
397
+ };
398
+ /**
399
+ * The conditions inside `or()`, `and()` or `not()`: every filter method, plus
400
+ * nested groups. Chain them on `q`; the callback's return value is ignored.
401
+ */
402
+ var FilterGroup = class extends Filters {
403
+ /** @internal */ filters = [];
404
+ /** @internal */
405
+ push(filter) {
406
+ this.filters.push(filter);
407
+ }
408
+ };
409
+ /**
410
+ * What reads and writes share: filters, step names and sending. Awaiting a
411
+ * builder sends the request and resolves to `{ data, error, count, status }`;
412
+ * it never throws. `T` is the row type, `R` the type of `data`.
413
+ */
414
+ var Builder = class extends Filters {
415
+ /** @internal */ transport;
416
+ /** @internal */ req;
417
+ #name;
418
+ /** @internal */
419
+ constructor(transport, req, name, problem) {
420
+ super();
421
+ this.transport = transport;
422
+ this.req = req;
423
+ this.#name = name;
424
+ this.problem = problem;
425
+ }
426
+ /** @internal */
427
+ push(filter) {
428
+ this.req.filters.push(filter);
429
+ }
430
+ /**
431
+ * Name a step in the array form of `transaction()`, so later steps can use
432
+ * its rows with `tx.ref('name.column')`. (In the object form the key is the name.)
433
+ *
434
+ * @example
435
+ * db.transaction((tx) => [
436
+ * tx.from('posts').insert({ title, user_id }).returning('id').as('post'),
437
+ * tx.from('comments').insert({ post_id: tx.ref('post.id'), body, author_id }),
438
+ * ])
439
+ */
440
+ as(name) {
441
+ this.#name = name;
442
+ return this;
443
+ }
444
+ /** @internal The `.as()` name, if any. */
445
+ stepName() {
446
+ return this.#name;
447
+ }
448
+ /** @internal A problem found while building, reported instead of sending. */
449
+ invalid() {
450
+ return this.problem;
451
+ }
452
+ /** @internal The request as sent to POST /query. */
453
+ request() {
454
+ return this.req;
455
+ }
456
+ /** @internal The request as sent to the server. */
457
+ toStep() {
458
+ return this.#name ? {
459
+ name: this.#name,
460
+ ...this.req
461
+ } : { ...this.req };
462
+ }
463
+ then(onfulfilled, onrejected) {
464
+ const run = async () => {
465
+ const invalid = this.invalid();
466
+ if (invalid) return {
467
+ data: null,
468
+ error: { message: invalid },
469
+ count: null,
470
+ status: 0
471
+ };
472
+ if (!this.transport) return {
473
+ data: null,
474
+ error: { message: "this query belongs to a transaction: return it from the transaction callback instead of awaiting it" },
475
+ count: null,
476
+ status: 0
477
+ };
478
+ return this.transport.post("/query", this.request());
479
+ };
480
+ return run().then(onfulfilled, onrejected);
481
+ }
482
+ };
483
+ /**
484
+ * Supabase-style query builder. Reads use `select()` and its modifiers;
485
+ * `insert()` / `upsert()` / `update()` / `delete()` turn it into a
486
+ * {@link WriteBuilder}. See GUIDE.md for recipes.
487
+ *
488
+ * @example
489
+ * const { data, error } = await db.from('users').select('id, name').eq('active', true).random().limit(5)
490
+ */
491
+ var QueryBuilder = class extends Builder {
492
+ #single = false;
493
+ #defaultReadFrom;
494
+ /** @internal Use `client.from()` or `tx.from()`. */
495
+ constructor(transport, table, readFrom) {
496
+ super(transport, {
497
+ table,
498
+ action: "select",
499
+ columns: "*",
500
+ filters: [],
501
+ order: [],
502
+ returning: false,
503
+ count: false
504
+ });
505
+ this.#defaultReadFrom = readFrom;
506
+ }
507
+ /**
508
+ * Columns to return. `'*'` (the default) means all. Supports aliases, jsonb
509
+ * paths, casts, related tables and related counts.
510
+ *
511
+ * `{ count: 'exact' }` also fills `count`; add `head: true` to skip the rows.
512
+ *
513
+ * @example
514
+ * db.from('users').select() // all columns
515
+ * db.from('users').select('id, fullName:name, city:meta->>city, score:meta->>score::int')
516
+ * db.from('posts').select('title, author:users!user_id(name), comments(body), labels(name)')
517
+ * db.from('users').select('name, posts!user_id!inner(title)') // only users that have posts
518
+ * db.from('users').select('name, posts!user_id(count)') // posts: [{ count: 2 }]
519
+ * db.from('posts').select('*', { count: 'exact' }).range(0, 9) // rows + total
520
+ *
521
+ * Extra `raw(...).as(name)` arguments add SQL expressions of the main table
522
+ * as columns (options, if any, go last).
523
+ *
524
+ * @example
525
+ * db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
526
+ */
527
+ select(columns = "*", ...extra) {
528
+ this.req.columns = columns;
529
+ const rawColumns = [];
530
+ for (const e of extra) if (e instanceof Raw) {
531
+ const as = e.alias();
532
+ if (as === void 0) this.problem ??= `raw("${e.sql}") in select() needs a column name: add .as('name')`;
533
+ else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(as)) this.problem ??= `raw(...).as("${as}"): the name must be letters, digits and _`;
534
+ else rawColumns.push({
535
+ ...e.fragment(),
536
+ as
537
+ });
538
+ } else if (e) {
539
+ if (e.count) this.req.count = true;
540
+ if (e.head) this.req.head = true;
541
+ }
542
+ if (rawColumns.length) this.req.rawColumns = rawColumns;
543
+ else delete this.req.rawColumns;
544
+ return this;
545
+ }
546
+ /**
547
+ * Number of matching rows: `data` is the number. All filters apply, including
548
+ * jsonb paths and `relation.column` filters on `!inner` relations.
549
+ *
550
+ * @example
551
+ * const { data: total } = await db.from('users').count() // 100
552
+ * const { data: active } = await db.from('users').eq('active', true).count() // 75
553
+ */
554
+ count(options = {}) {
555
+ if (options.distinct) return this.#aggregate("count", options.distinct, true);
556
+ this.req.countOnly = true;
557
+ return this;
558
+ }
559
+ /**
560
+ * Smallest value of a column over the matching rows: `data` is that value,
561
+ * or `null` when no rows match. `max`, `sum` and `avg` work the same way.
562
+ *
563
+ * @example
564
+ * const { data: first } = await db.from('posts').eq('user_id', id).min('created_at')
565
+ */
566
+ min(column) {
567
+ return this.#aggregate("min", column);
568
+ }
569
+ /** Largest value. @example .eq('email', email).max('created_at') */
570
+ max(column) {
571
+ return this.#aggregate("max", column);
572
+ }
573
+ /** Sum of a number column, `null` when no rows match. */
574
+ sum(column) {
575
+ return this.#aggregate("sum", column);
576
+ }
577
+ /** Average of a number column, `null` when no rows match. */
578
+ avg(column) {
579
+ return this.#aggregate("avg", column);
580
+ }
581
+ #aggregate(fn, column, distinct) {
582
+ this.req.aggregate = distinct ? {
583
+ fn,
584
+ column,
585
+ distinct
586
+ } : {
587
+ fn,
588
+ column
589
+ };
590
+ return this;
591
+ }
592
+ /**
593
+ * Group rows for aggregates in `select()`: `count()`, `sum(col)`, `avg(col)`,
594
+ * `min(col)`, `max(col)`, `count(distinct col)`. Every plain column in the
595
+ * select must be grouped. `order()` can use an aggregate's name.
596
+ *
597
+ * @example
598
+ * db.from('posts').select('user_id, n:count(), last:max(created_at)').groupBy('user_id').order('n', { ascending: false })
599
+ */
600
+ groupBy(columns) {
601
+ this.req.groupBy = columns.split(",").map((c) => c.trim()).filter(Boolean);
602
+ return this;
603
+ }
604
+ /**
605
+ * Keep only the groups where the raw condition holds, knex's `havingRaw`.
606
+ * Use with `groupBy()`; several calls are joined with AND.
607
+ *
608
+ * @example
609
+ * db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
610
+ */
611
+ having(fragment) {
612
+ if (fragment instanceof Raw) (this.req.having ??= []).push(fragment.fragment());
613
+ else this.problem ??= "having() takes a raw() fragment: having(db.raw(\"count(*) >= ?\", [2]))";
614
+ return this;
615
+ }
616
+ /**
617
+ * Leave out repeated rows: `SELECT DISTINCT`.
618
+ *
619
+ * @example
620
+ * db.from('posts').select('user_id').distinct()
621
+ */
622
+ distinct() {
623
+ this.req.distinct = true;
624
+ return this;
625
+ }
626
+ /**
627
+ * Insert one or more rows. Values may contain `rel.*` to write related rows
628
+ * in the same transaction. Add `.returning()` to get the new rows back.
629
+ *
630
+ * @example
631
+ * await db.from('users').insert({ name, email }) // data: null, count: 1
632
+ * const { data } = await db.from('users').insert({ name, email }).returning('id') // [{ id }]
633
+ * await db.from('posts').insert({ title, user_id, comments: rel.create([{ body, author_id }]) })
634
+ */
635
+ insert(values) {
636
+ return this.#write("insert", Array.isArray(values) ? values : [values]);
637
+ }
638
+ /**
639
+ * Insert rows, or handle an existing row with the same `onConflict` key
640
+ * (needs a unique constraint; default: primary key): update it, or with
641
+ * `ignoreDuplicates: true` return it unchanged (find-or-create). `merge`
642
+ * picks which columns change and how. Up to 5,000 rows per call, each key
643
+ * at most once.
644
+ *
645
+ * @example
646
+ * // find-or-create
647
+ * const { data } = await db.from('users')
648
+ * .upsert({ email, name }, { onConflict: 'email', ignoreDuplicates: true })
649
+ * .returning('id')
650
+ * data[0].id
651
+ * // insert or update
652
+ * await db.from('users').upsert({ email, name }, { onConflict: 'email' })
653
+ * // many rows: add the counters, set the rest
654
+ * await db.from('posts_stats').upsert(rows, {
655
+ * onConflict: 'post_id',
656
+ * merge: { views: 'add', likes: 'add', last_viewer_id: 'set' },
657
+ * })
658
+ */
659
+ upsert(rows, options = {}) {
660
+ const { onConflict } = conflict(options.onConflict);
661
+ const { merge } = options;
662
+ return this.#write("insert", Array.isArray(rows) ? rows : [rows], {
663
+ upsert: true,
664
+ ...options.ignoreDuplicates && { ignoreDuplicates: true },
665
+ ...onConflict && { onConflict },
666
+ ...merge && { merge: Array.isArray(merge) ? Object.fromEntries(merge.map((c) => [c, "set"])) : merge }
667
+ });
668
+ }
669
+ /**
670
+ * Update every matching row (needs at least one filter). Values may be plain
671
+ * (full replace), `ops.*` (atomic change) or `rel.*` (related rows).
672
+ *
673
+ * @example
674
+ * await db.from('users').update({ name: 'New' }).eq('id', id)
675
+ * await db.from('users').update({ scores: ops.append(4), age: ops.increment() }).eq('id', id).returning('age')
676
+ * await db.from('posts').update({ labels: rel.set([labelId]) }).eq('id', postId)
677
+ */
678
+ update(values) {
679
+ return this.#write("update", [values]);
680
+ }
681
+ /**
682
+ * Delete every matching row (needs at least one filter).
683
+ *
684
+ * @example
685
+ * await db.from('posts').delete().eq('id', postId)
686
+ * await db.from('posts').delete().eq('id', postId).returning('title') // the deleted rows
687
+ */
688
+ delete() {
689
+ return this.#write("delete", void 0);
690
+ }
691
+ #write(action, values, extra = {}, problem) {
692
+ const { readFrom, ...rest } = this.req;
693
+ const req = {
694
+ ...rest,
695
+ ...extra,
696
+ action,
697
+ columns: "*"
698
+ };
699
+ if (values) req.values = values;
700
+ if (readFrom === "replica") problem ??= `replica() only works with select(); ${action} always runs on the primary`;
701
+ return new WriteBuilder(this.transport, req, this.stepName(), problem ?? this.invalid());
702
+ }
703
+ /**
704
+ * Let this read run on a read replica. Replicas can be a moment behind the
705
+ * primary, so use it where slightly stale rows are fine (lists, search,
706
+ * counts), not right after writing the rows you read. Without a healthy
707
+ * replica on the server it reads from the primary. Reads only, not inside
708
+ * a transaction.
709
+ *
710
+ * @example
711
+ * await db.from('posts').select('title').eq('published', true).replica()
712
+ */
713
+ replica() {
714
+ this.req.readFrom = "replica";
715
+ return this;
716
+ }
717
+ /**
718
+ * Read from the primary, overriding `createClient(..., { readFrom: 'replica' })`.
719
+ *
720
+ * @example
721
+ * await db.from('users').select().eq('id', id).primary() // just wrote it: read your own write
722
+ */
723
+ primary() {
724
+ this.req.readFrom = "primary";
725
+ return this;
726
+ }
727
+ /** @internal Applies the client's readFrom default to reads. */
728
+ request() {
729
+ if (this.req.readFrom || !this.#defaultReadFrom) return this.req;
730
+ return {
731
+ ...this.req,
732
+ readFrom: this.#defaultReadFrom
733
+ };
734
+ }
735
+ /**
736
+ * Sort. Chain several: they apply in the order written. Ascending by default.
737
+ * With `referencedTable` it sorts the rows inside a relation.
738
+ *
739
+ * @example
740
+ * .order('active', { ascending: false }).order('age', { ascending: false }).order('name')
741
+ * .order('meta->>score::int', { ascending: false, nullsFirst: false })
742
+ * .order('title', { referencedTable: 'posts' }) // posts inside each user
743
+ * .order(db.raw("coalesce(last_read_time, '1970-01-01')")) // raw, main table only
744
+ */
745
+ order(column, options = {}) {
746
+ const { ascending = true, nullsFirst } = options;
747
+ if (column instanceof Raw) {
748
+ if (embedOf(options)) this.problem ??= "order(raw(...)) sorts the main table only: drop referencedTable";
749
+ else this.req.order.push({
750
+ raw: column.fragment(),
751
+ ascending,
752
+ nullsFirst
753
+ });
754
+ return this;
755
+ }
756
+ this.req.order.push({
757
+ column,
758
+ ascending,
759
+ nullsFirst,
760
+ table: embedOf(options)
761
+ });
762
+ return this;
763
+ }
764
+ /**
765
+ * Random order. After other `order()` calls it only shuffles ties. With a
766
+ * `seed` the order is stable (same seed, same order), so `range()` pages never
767
+ * repeat or skip rows.
768
+ *
769
+ * @example
770
+ * .random().limit(5) // 5 random rows
771
+ * .order('active', { ascending: false }).random() // active first, each group shuffled
772
+ * .random({ seed: sessionId }).range(10, 19) // stable page 2
773
+ * .random({ referencedTable: 'posts' }).limit(3, { referencedTable: 'posts' }) // 3 random posts each
774
+ */
775
+ random(options = {}) {
776
+ const seed = options.seed === void 0 ? void 0 : String(options.seed);
777
+ this.req.order.push({
778
+ random: true,
779
+ seed,
780
+ table: embedOf(options)
781
+ });
782
+ return this;
783
+ }
784
+ /**
785
+ * At most `count` rows: always an array, `[]` when none match, even for
786
+ * `limit(1)`. With `referencedTable`, at most `count` related rows per parent.
787
+ *
788
+ * @example
789
+ * .limit(10)
790
+ * .limit(3, { referencedTable: 'posts' })
791
+ */
792
+ limit(count, options = {}) {
793
+ const embed = embedOf(options);
794
+ if (embed) this.req.embedLimits = {
795
+ ...this.req.embedLimits,
796
+ [embed]: count
797
+ };
798
+ else this.req.limit = count;
799
+ return this;
800
+ }
801
+ /**
802
+ * Rows `from` to `to`, inclusive, 0-based.
803
+ *
804
+ * @example
805
+ * .range(0, 9) // page 1
806
+ * .range(10, 19) // page 2
807
+ */
808
+ range(from, to) {
809
+ this.req.offset = from;
810
+ this.req.limit = to - from + 1;
811
+ return this;
812
+ }
813
+ /**
814
+ * One row: `data` is that row as an object, or `null` when nothing matches
815
+ * (not an error). Adds `LIMIT 1`, so don't combine it with `limit()` or
816
+ * `range()`. With several matches it is the first in your `order()`.
817
+ *
818
+ * @example
819
+ * const { data: user } = await db.from('users').select().eq('id', id).single()
820
+ * if (!user) { ... }
821
+ * const { data: email } = await db.from('emails').select().eq('status', 'active').random().single()
822
+ */
823
+ single() {
824
+ this.#single = true;
825
+ this.req.single = "single";
826
+ return this;
827
+ }
828
+ /** @internal */
829
+ invalid() {
830
+ if (this.problem) return this.problem;
831
+ if (this.#single && (this.req.limit !== void 0 || this.req.offset !== void 0)) return "single() already means one row: don't combine it with limit() or range()";
832
+ }
833
+ };
834
+ /**
835
+ * An insert, upsert, update or delete. Like Postgres, it writes every
836
+ * matching row; `data` is `null` and `count` the number of rows written,
837
+ * unless `returning()` asks for the rows.
838
+ */
839
+ var WriteBuilder = class extends Builder {
840
+ /**
841
+ * Return the written rows, like Postgres `RETURNING`: always an array (one
842
+ * row is `[row]`, none is `[]`). Takes `'*'` (the default) or column names.
843
+ *
844
+ * @example
845
+ * await db.from('users').insert({ name, email }).returning() // [{ id, name, email, ... }]
846
+ * await db.from('users').update({ age: ops.increment() }).eq('id', id).returning('id, age')
847
+ * await db.from('posts').delete().eq('user_id', id).returning('id') // every deleted row
848
+ */
849
+ returning(columns = "*") {
850
+ this.req.columns = columns;
851
+ this.req.returning = true;
852
+ return this;
853
+ }
854
+ };
855
+ // src/ops.ts
856
+ /**
857
+ * Atomic update operator, applied by Postgres to the current column value.
858
+ *
859
+ * update({ scores: ops.append(4), meta: ops.merge({ vip: true }), age: ops.increment() })
860
+ */
861
+ var Op = class Op {
862
+ $op;
863
+ values;
864
+ path;
865
+ constructor(op, values, path = []) {
866
+ this.$op = op;
867
+ this.values = values;
868
+ this.path = path;
869
+ }
870
+ /** Apply at a JSON path inside a json/jsonb column: `ops.append('x').at('address', 'lines')`. */
871
+ at(...path) {
872
+ return new Op(this.$op, this.values, path.map(String));
873
+ }
874
+ };
875
+ const ops = {
876
+ /**
877
+ * Add values at the end of a jsonb array or Postgres array. An array argument is added as one element.
878
+ * @example
879
+ * update({ scores: ops.append(4) }) // [1,2,3] -> [1,2,3,4]
880
+ * update({ meta: ops.append('y').at('tags') }) // inside a jsonb object
881
+ */
882
+ append: (...values) => new Op("append", values),
883
+ /**
884
+ * Add values at the start of a jsonb array or Postgres array.
885
+ * @example
886
+ * update({ scores: ops.prepend(0) }) // [1,2,3] -> [0,1,2,3]
887
+ */
888
+ prepend: (...values) => new Op("prepend", values),
889
+ /**
890
+ * Remove every element equal to one of the values.
891
+ * @example
892
+ * update({ scores: ops.remove(2) }) // [1,2,3] -> [1,3]
893
+ */
894
+ remove: (...values) => new Op("remove", values),
895
+ /**
896
+ * Shallow-merge keys into a json/jsonb object.
897
+ * @example
898
+ * update({ meta: ops.merge({ vip: true }) }) // {a:1} -> {a:1,vip:true}
899
+ */
900
+ merge: (object) => new Op("merge", [object]),
901
+ /**
902
+ * Set a value at a JSON path (needs `.at(...)`); a number is an array position.
903
+ * @example
904
+ * update({ meta: ops.set('Lisbon').at('city') })
905
+ * update({ scores: ops.set(9).at(0) }) // [1,2,3] -> [9,2,3]
906
+ */
907
+ set: (value) => new Op("set", [value]),
908
+ /**
909
+ * Remove the key (or array position) at a JSON path (needs `.at(...)`).
910
+ * @example
911
+ * update({ meta: ops.unset().at('vip') })
912
+ * update({ scores: ops.unset().at(0) }) // [1,2,3] -> [2,3]
913
+ */
914
+ unset: () => new Op("unset", []),
915
+ /**
916
+ * Add to a number column, or to a number at a JSON path (negative to subtract).
917
+ * @example
918
+ * update({ age: ops.increment() })
919
+ * update({ meta: ops.increment(5).at('points') })
920
+ */
921
+ increment: (by = 1) => new Op("increment", [by])
922
+ };
923
+ // src/client.ts
924
+ const fail = (message) => ({
925
+ data: null,
926
+ error: { message },
927
+ count: null,
928
+ status: 0
929
+ });
930
+ function stepProblem(s) {
931
+ if (s.toStep().readFrom === "replica") return "transaction steps always run on the primary: drop replica()";
932
+ return s.invalid();
933
+ }
934
+ /** Turn the callback's result into wire steps; null + message when it is not usable. */
935
+ function toSteps(steps) {
936
+ if (Array.isArray(steps)) {
937
+ if (steps.length === 0) return "transaction callback must return a non-empty array or object of steps";
938
+ for (const s of steps) {
939
+ if (!(s instanceof QueryBuilder || s instanceof WriteBuilder)) return "every transaction step must be tx.from(...)";
940
+ const bad = stepProblem(s);
941
+ if (bad) return bad;
942
+ }
943
+ return {
944
+ names: null,
945
+ wire: steps.map((s) => s.toStep())
946
+ };
947
+ }
948
+ if (steps && typeof steps === "object") {
949
+ const entries = Object.entries(steps);
950
+ if (entries.length === 0) return "transaction callback must return a non-empty array or object of steps";
951
+ for (const [name, s] of entries) {
952
+ if (/^(0|[1-9]\d*)$/.test(name)) return `step name "${name}" looks like a number; use a word (JavaScript reorders number keys)`;
953
+ if (name.includes(".")) return `step name "${name}" must not contain a dot (refs use name.column)`;
954
+ if (!(s instanceof QueryBuilder || s instanceof WriteBuilder)) return `step "${name}" must be tx.from(...)`;
955
+ if (s.stepName()) return `step "${name}" already has a name; drop .as("${s.stepName()}") in the object form`;
956
+ const bad = stepProblem(s);
957
+ if (bad) return bad;
958
+ }
959
+ return {
960
+ names: entries.map(([n]) => n),
961
+ wire: entries.map(([name, s]) => ({
962
+ ...s.toStep(),
963
+ name
964
+ }))
965
+ };
966
+ }
967
+ return "transaction callback must return an array or object of steps";
968
+ }
969
+ function createTransport(url, apiKey, options) {
970
+ const base = url.replace(/\/+$/, "");
971
+ const fetchImpl = options.fetch ?? globalThis.fetch;
972
+ if (!fetchImpl) throw new Error("arn-db: no fetch available; pass options.fetch (Node 18+ has a global fetch)");
973
+ if (typeof apiKey !== "string" || apiKey === "") throw new Error("arn-db: createClient(url, apiKey) needs an API key");
974
+ return { async post(path, body) {
975
+ let res;
976
+ try {
977
+ res = await fetchImpl(base + path, {
978
+ method: "POST",
979
+ headers: {
980
+ ...options.headers,
981
+ "Content-Type": "application/json",
982
+ apikey: apiKey
983
+ },
984
+ body: JSON.stringify(body)
985
+ });
986
+ } catch (err) {
987
+ return {
988
+ data: null,
989
+ error: { message: err.message },
990
+ count: null,
991
+ status: 0
992
+ };
993
+ }
994
+ let json;
995
+ try {
996
+ json = await res.json();
997
+ } catch {
998
+ return {
999
+ data: null,
1000
+ error: { message: `invalid response (HTTP ${res.status})` },
1001
+ count: null,
1002
+ status: res.status
1003
+ };
1004
+ }
1005
+ if (json.error) return {
1006
+ data: null,
1007
+ error: json.error,
1008
+ count: null,
1009
+ status: res.status
1010
+ };
1011
+ return {
1012
+ data: json.data ?? null,
1013
+ error: null,
1014
+ count: json.count ?? null,
1015
+ status: res.status
1016
+ };
1017
+ } };
1018
+ }
1019
+ /**
1020
+ * Server-side client for the Go API (it refuses browser requests). Every call
1021
+ * resolves to `{ data, error, count, status }` and never throws.
1022
+ *
1023
+ * @example
1024
+ * const db = createClient(process.env.PG_API_URL!, process.env.PG_API_KEY!)
1025
+ * const { data, error } = await db.from('users').select().eq('active', true)
1026
+ */
1027
+ function createClient(url, apiKey, options = {}) {
1028
+ const transport = createTransport(url, apiKey, options);
1029
+ const { readFrom } = options;
1030
+ if (readFrom !== void 0 && readFrom !== "primary" && readFrom !== "replica") throw new Error(`arn-db: readFrom must be 'primary' or 'replica', got ${JSON.stringify(readFrom)}`);
1031
+ return {
1032
+ from: (table) => new QueryBuilder(transport, table, readFrom),
1033
+ ops,
1034
+ rel,
1035
+ col,
1036
+ now,
1037
+ raw,
1038
+ async transaction(build) {
1039
+ const steps = toSteps(build({
1040
+ from: (table) => new QueryBuilder(null, table),
1041
+ ref: (path) => ({ $ref: path }),
1042
+ ops,
1043
+ rel,
1044
+ col,
1045
+ now,
1046
+ raw
1047
+ }));
1048
+ if (typeof steps === "string") return fail(steps);
1049
+ const res = await transport.post("/transaction", { steps: steps.wire });
1050
+ if (res.error || !steps.names) return res;
1051
+ const results = res.data;
1052
+ const byName = Object.fromEntries(steps.names.map((n, i) => [n, results[i]]));
1053
+ return {
1054
+ ...res,
1055
+ data: byName
1056
+ };
1057
+ }
1058
+ };
1059
+ }
1060
+ // src/env.ts
1061
+ let client;
1062
+ /** The ARN_DB_URL / ARN_DB_KEY client, made on the first query (so a later dotenv still counts). */
1063
+ function envClient() {
1064
+ if (client) return client;
1065
+ const proc = globalThis.process;
1066
+ if (!proc) throw new Error("arn-db: `db` reads ARN_DB_URL / ARN_DB_KEY from process.env; use createClient(url, key) here");
1067
+ if ((!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) && proc.loadEnvFile) try {
1068
+ proc.loadEnvFile();
1069
+ } catch {}
1070
+ const url = proc.env.ARN_DB_URL;
1071
+ const key = proc.env.ARN_DB_KEY;
1072
+ if (!url) throw new Error("arn-db: set ARN_DB_URL (e.g. https://your-api.example.com/v2/raw) or use createClient(url, key)");
1073
+ if (!key) throw new Error("arn-db: set ARN_DB_KEY or use createClient(url, key)");
1074
+ client = createClient(url, key);
1075
+ return client;
1076
+ }
1077
+ /**
1078
+ * Ready-made client from the environment: `ARN_DB_URL` (e.g.
1079
+ * `https://your-api.example.com/v2/raw`) and `ARN_DB_KEY`, read on the first query,
1080
+ * with `./.env` loaded if they are not set yet. A missing variable throws on
1081
+ * that first query. Use `createClient()` for other keys or options.
1082
+ *
1083
+ * @example
1084
+ * import { db } from 'arn-db'
1085
+ * const { data, error } = await db.from('users').select().eq('id', id).single()
1086
+ */
1087
+ const db = {
1088
+ from: (table) => envClient().from(table),
1089
+ transaction: ((build) => envClient().transaction(build)),
1090
+ ops,
1091
+ rel,
1092
+ col,
1093
+ now,
1094
+ raw
1095
+ };
1096
+ export { Col, FilterGroup, Now, Op, QueryBuilder, Raw, WriteBuilder, col, createClient, db, now, ops, raw, rel };
1097
+
1098
+ //# sourceMappingURL=index.mjs.map