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