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