arn-db 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GUIDE.md CHANGED
@@ -30,6 +30,7 @@ Contents:
30
30
  [Writing related rows](#writing-related-rows) ·
31
31
  [Transactions](#transactions) ·
32
32
  [Read replicas](#read-replicas) ·
33
+ [Raw SQL fragments](#raw-sql-fragments) ·
33
34
  [Errors](#errors) ·
34
35
  [Not supported yet](#not-supported-yet)
35
36
 
@@ -54,10 +55,12 @@ await db.from('users').update({ scores: db.ops.append(4) }).eq('id', id)
54
55
  await db.from('posts').insert({ title, user_id, comments: db.rel.create([{ body, author_id }]) })
55
56
  ```
56
57
 
57
- `db` reads the variables on its first query, not on import, so loading dotenv
58
- later in startup still works. If they aren't set yet, it loads `./.env` from the
59
- working directory first (Node 20.12+). Variables that are already set always win.
60
- A missing variable throws on that first query.
58
+ Importing arn-db loads `./.env` from the working directory into `process.env`
59
+ (Node 20.12+), like `import "dotenv/config"`: the app's other variables are set
60
+ too, so files need no dotenv import. Variables that are already set always win,
61
+ and a missing `.env` is fine. `db` reads its two variables on the first query (it
62
+ tries `./.env` once more then), so setting them later in startup still works. A
63
+ missing variable throws on that first query.
61
64
 
62
65
  Inside `transaction()` the same helpers are on `tx`: `tx.from()`, `tx.ref()`,
63
66
  `tx.ops.*`, `tx.rel.*`. (`import { ops, rel } from 'arn-db'` still works too.)
@@ -483,7 +486,8 @@ await db.from('posts').select('user_id').distinct() // SELECT DISTINCT
483
486
  ```
484
487
 
485
488
  Aggregates, `groupBy()` and `distinct()` don't combine with related tables in the
486
- same select. Not yet: `having`.
489
+ same select. For `HAVING`, use `.having(db.raw('count(*) >= ?', [2]))` (see
490
+ [Raw SQL fragments](#raw-sql-fragments)).
487
491
 
488
492
  ---
489
493
 
@@ -700,6 +704,98 @@ await db.from('users').select().eq('email', email).primary()
700
704
 
701
705
  ---
702
706
 
707
+ ## Raw SQL fragments
708
+
709
+ When the builder has no method for it, `db.raw(sql, bindings?)` adds one SQL
710
+ expression to the query (same as `import { raw }`, and `tx.raw` in
711
+ transactions). It is knex's `whereRaw` / `orderByRaw` / `havingRaw` /
712
+ `knex.raw`: a piece of a query, never a whole statement. `test/raw.test.ts`
713
+ checks these against the test database.
714
+
715
+ ```ts
716
+ const n = db.raw('substring(name from 6)::int') // user_42 -> 42
717
+
718
+ // where(): AND (<fragment>)
719
+ await db.from('users').select('name').where(db.raw('substring(name from 6)::int <= ?', [3])).order('name')
720
+ // [{ name: 'user_1' }, { name: 'user_2' }, { name: 'user_3' }]
721
+
722
+ // inside or() / and() / not() it is one condition of the group
723
+ await db.from('users').select('name')
724
+ .or((q) => q.where(db.raw('substring(name from 6)::int = ?', [1])).eq('name', 'user_2'))
725
+ // user_1, user_2
726
+
727
+ // array binding: = any(?)
728
+ await db.from('users').select('name').where(db.raw('lower(trim(email)) = any(?)', [['user_1@example.com', 'user_7@example.com']]))
729
+
730
+ // jsonb ? operator: \? (written '\\?' in JS) is a literal ?
731
+ await db.from('users').where(db.raw('meta \\? ?', ['vip'])).count() // 100
732
+
733
+ // order()
734
+ await db.from('users').select('name').order(n, { ascending: false }).limit(3)
735
+ // user_100, user_99, user_98
736
+
737
+ // select(): extra arguments are raw columns, each needs .as(name); options go last
738
+ await db.from('users').select('name', db.raw('char_length(name)').as('name_len')).eq('name', 'user_42').single()
739
+ // { name: 'user_42', name_len: 7 }
740
+
741
+ // having(), with groupBy()
742
+ await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
743
+ // 33 groups, each n: 2
744
+ ```
745
+
746
+ As values in `insert()` / `update()` / `upsert()` rows, and in upsert `merge`,
747
+ where `excluded.col` is the incoming value and `table.col` the stored one:
748
+
749
+ ```ts
750
+ await db.from('users').update({ age: db.raw('age + ?', [5]) }).eq('email', email).returning('age') // 10 -> [{ age: 15 }]
751
+
752
+ await db.from('users').upsert({ email, name: 'second', age: 5 }, {
753
+ onConflict: 'email',
754
+ merge: { age: db.raw('CASE WHEN excluded.age IS NOT NULL AND users.age IS NOT NULL THEN users.age + excluded.age ELSE ? END', [100]) },
755
+ })
756
+ // stored age 10 -> 15; name stays (only merge columns change); with age: null -> 100
757
+ ```
758
+
759
+ Rules:
760
+
761
+ - Each `?` is one binding, passed as a query parameter, so values are never
762
+ pasted into the SQL. The count must match `bindings`, else a 400 error
763
+ (`raw fragment: 2 placeholders but 1 binding`). Strings, numbers, booleans and
764
+ `null` pass through; an array becomes a Postgres array (cast it if needed:
765
+ `?::text[]`); an object becomes jsonb (`?::jsonb`). A `?` inside `'quoted
766
+ text'` is not a binding.
767
+ - `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator.
768
+ - Fragments see the main table only: its columns, plain or qualified with the
769
+ table's name (`users.age`, `"users".age`), and `excluded.col` in `merge`.
770
+ Related tables are not visible, and `order(raw)` with `referencedTable` is an error.
771
+ - The API key must be allowed to use raw fragments. Otherwise every request
772
+ (or transaction) that contains one gets 403, code `RAW_NOT_ALLOWED`.
773
+ - The server rejects with 400, code `RAW_FRAGMENT`: `;`, `--` and `/*`
774
+ comments, dollar quoting (`$$`), the keywords `select`, `insert`, `update`,
775
+ `delete`, `with`, `union`, `into`, `copy`, `grant`, `drop`, `alter`,
776
+ `create`, `truncate`, `execute`, `do`, `call` outside quoted text, an empty
777
+ fragment or one over 2,000 characters, and more than 100 bindings.
778
+ Functions, `CASE WHEN`, casts, `interval '1 hour'`, operators, `count(*)`
779
+ and `excluded.x` are fine.
780
+ - In the client, `raw()` throws when `sql` is not a non-empty string or
781
+ `bindings` not an array. `select()` with a raw column lacking `.as()`, and
782
+ `where()` / `having()` with something other than `raw()`, resolve to `{ error }`
783
+ without sending.
784
+
785
+ Moving from knex:
786
+
787
+ | knex | arn-db |
788
+ |---|---|
789
+ | `.whereRaw(sql, b)`, `.andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
790
+ | `.orWhereRaw(sql, b)` | `.or((q) => q.eq(...).where(db.raw(sql, b)))` |
791
+ | `.orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending, nullsFirst })` |
792
+ | `.select('id', knex.raw('char_length(title) as n'))` | `.select('id', db.raw('char_length(title)').as('n'))` |
793
+ | `{ total: knex.raw('total + ?', [1]) }` | `{ total: db.raw('total + ?', [1]) }` |
794
+ | `.onConflict('email').merge({ total: knex.raw('…') })` | `.upsert(rows, { onConflict: 'email', merge: { total: db.raw('…') } })` |
795
+ | `.havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
796
+
797
+ ---
798
+
703
799
  ## Errors
704
800
 
705
801
  `error` has `message`, and usually `code` and `details`:
@@ -712,11 +808,13 @@ await db.from('users').select().eq('email', email).primary()
712
808
  | `22P02` | value has the wrong format | `.eq('id', 'abc')` on a uuid |
713
809
  | `42703` | column doesn't exist | `select('nope')` → `column _t0.nope does not exist` (`_t0` is the main table) |
714
810
  | `42P10` | `onConflict` columns have no unique constraint | `upsert(…, { onConflict: 'title' })` |
811
+ | `RAW_FRAGMENT` | (400) a `db.raw()` fragment the server refuses | `db.raw('1; drop table x')` |
812
+ | `RAW_NOT_ALLOWED` | (403) the API key may not use raw fragments | |
715
813
  | `401` | missing or wrong API key | |
716
814
  | `403` | request came from a browser | |
717
815
 
718
- Only setup mistakes throw: `createClient()` without a key, and `db`'s first
719
- query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
816
+ Only setup mistakes throw: `createClient()` without a key, `db.raw()` without a
817
+ SQL string, and `db`'s first query when `ARN_DB_URL` or `ARN_DB_KEY` is missing.
720
818
 
721
819
  Errors without a code are checks done before the database runs anything, for
722
820
  example: `more than one relationship between "posts" and "users", use one of: users!reviewer_id, users!user_id`.
@@ -727,6 +825,7 @@ example: `more than one relationship between "posts" and "users", use one of: us
727
825
 
728
826
  - Sorting main rows by a related table's column (e.g. posts by author name). Sorting *inside* a relation works.
729
827
  - OR across tables in one group (e.g. `name = 'x' OR posts.published`): each `or()` group is on one table.
730
- - Full-text search, calling Postgres functions (`rpc`), `having` for groups, column
731
- functions like `lower(trim(email))`.
828
+ - Full-text search and calling Postgres functions (`rpc`) as methods. Column
829
+ functions like `lower(trim(email))` and `having` work through
830
+ [raw fragments](#raw-sql-fragments).
732
831
  - `db.col()` across tables, and as a value in `insert()` / `update()`.
package/README.md CHANGED
@@ -34,15 +34,16 @@ import { db } from 'arn-db' // or: const { db } = require('arn-db')
34
34
  const { data, error } = await db.from('users').select().eq('id', 1).single()
35
35
  ```
36
36
 
37
- That's all: `db` reads the two variables on its first query. If they aren't set,
38
- it loads `./.env` first (Node 20.12+), as arn-knexjs did with dotenv. ESM, CommonJS and
39
- TypeScript types are included.
37
+ That's all. Importing arn-db loads `./.env` into `process.env` (Node 20.12+;
38
+ variables that are already set win), like arn-knexjs's `dotenv/config`, so your
39
+ files need no dotenv import. `db` reads the two variables on its first query.
40
+ ESM, CommonJS and TypeScript types are included.
40
41
 
41
42
  ## Quick tour
42
43
 
43
44
  Import the ready `db` everywhere: `db.from()`, `db.transaction()`, `db.ops.*`,
44
- `db.rel.*`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
45
- `tx.rel.*`, `tx.ref()`). For another key or options, make your own client with
45
+ `db.rel.*`, `db.raw()`. Inside transactions the same helpers are on `tx` (`tx.ops.*`,
46
+ `tx.rel.*`, `tx.raw()`, `tx.ref()`). For another key or options, make your own client with
46
47
  `createClient(url, key, options)`.
47
48
 
48
49
  ```ts
@@ -90,8 +91,8 @@ await db.from('users').update({ meta: db.ops.append('x').at('address', 'lines')
90
91
 
91
92
  Errors never throw: every call (and every transaction) resolves to
92
93
  `{ data, error, count, status }`, like Supabase. Only setup mistakes throw:
93
- `createClient()` without an API key or fetch, and `db`'s first query when
94
- `ARN_DB_URL` / `ARN_DB_KEY` is missing.
94
+ `createClient()` without an API key or fetch, `db.raw()` without a SQL string,
95
+ and `db`'s first query when `ARN_DB_URL` / `ARN_DB_KEY` is missing.
95
96
 
96
97
  ## Searching arrays (jsonb arrays and Postgres arrays)
97
98
 
@@ -214,3 +215,53 @@ await db.transaction((tx) => [
214
215
  returned an array, pick one: `tx.ref('posts.0.id')`.
215
216
  - Step names: words without dots. Number-like keys (`'1'`) are rejected because
216
217
  JavaScript would reorder them.
218
+
219
+ ## Raw SQL fragments
220
+
221
+ For the bits the builder has no method for, `db.raw(sql, bindings?)` (also
222
+ `import { raw }` and `tx.raw`) adds one SQL expression to the query the builder
223
+ makes, like knex's `whereRaw` / `orderByRaw` / `knex.raw`. It is a fragment,
224
+ never a whole statement.
225
+
226
+ ```ts
227
+ // filters: AND (<fragment>), or one member of an or() group
228
+ await db.from('users').select('id').where(db.raw('lower(trim(email)) = any(?)', [emails]))
229
+ await db.from('accounts').select().or((q) => q.where(db.raw('sent_per_hour < limit_per_hour')).is('limit_per_hour', null))
230
+
231
+ // order, computed select columns (.as() is required), having
232
+ await db.from('accounts').select().order(db.raw("coalesce(last_read_time, '1970-01-01')"), { ascending: true })
233
+ await db.from('posts').select('id, title', db.raw('char_length(title)').as('title_len'))
234
+ await db.from('posts').select('user_id, n:count()').groupBy('user_id').having(db.raw('count(*) >= ?', [2]))
235
+
236
+ // values in insert / update / upsert rows, and in upsert merge (excluded.col = incoming value)
237
+ await db.from('accounts').update({ sent_total: db.raw('sent_total + ?', [1]) }).eq('id', id)
238
+ await db.from('accounts').upsert(rows, {
239
+ onConflict: 'email',
240
+ merge: { total: db.raw('CASE WHEN excluded.total IS NULL THEN accounts.total ELSE excluded.total END') },
241
+ })
242
+ ```
243
+
244
+ - Each `?` is a binding, sent as a query parameter (never pasted into the SQL).
245
+ Their number must match `bindings`. An array binding is a Postgres array
246
+ (`= any(?)`), an object is jsonb (`?::jsonb`). `?` inside `'quoted text'` is
247
+ not a binding.
248
+ - `\?` (in a JS string: `'\\?'`) is a literal `?`, for the jsonb `?` operator:
249
+ `db.raw('meta \\? ?', ['vip'])`.
250
+ - A fragment sees the main table's columns, plain or qualified with the table
251
+ name (`accounts.total`); not related tables.
252
+ - The API key must be allowed to use raw fragments; otherwise the request gets
253
+ 403 `RAW_NOT_ALLOWED`.
254
+ - The server rejects (400 `RAW_FRAGMENT`): `;`, `--` and `/* */` comments,
255
+ dollar quoting, statement keywords (`select`, `insert`, `update`, `delete`,
256
+ `with`, `union`, `into`, `drop`, `alter`, `create`, `truncate`, …) outside
257
+ quoted text, fragments over 2,000 characters and more than 100 bindings.
258
+
259
+ | knex | arn-db |
260
+ |---|---|
261
+ | `whereRaw(sql, b)` / `andWhereRaw(sql, b)` | `.where(db.raw(sql, b))` |
262
+ | `orWhereRaw(sql, b)` | `.or((q) => q.where(db.raw(sql, b)).eq(...))` |
263
+ | `orderByRaw(sql, b)` | `.order(db.raw(sql, b), { ascending })` |
264
+ | `select('id', knex.raw('… as x'))` | `.select('id', db.raw('…').as('x'))` |
265
+ | `{ col: knex.raw('col + ?', [1]) }` value | `{ col: db.raw('col + ?', [1]) }` |
266
+ | `merge({ col: knex.raw(...) })` | `upsert(rows, { merge: { col: db.raw(...) } })` |
267
+ | `havingRaw(sql, b)` | `.having(db.raw(sql, b))` |
package/dist/index.cjs CHANGED
@@ -152,10 +152,66 @@ var Now = class Now {
152
152
  };
153
153
  const col = (name) => new Col(name);
154
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);
155
209
  // src/builder.ts
156
210
  const embedOf = (o) => o.referencedTable ?? o.foreignTable;
157
211
  /** Filter methods, shared by builders and groups. */
158
212
  var Filters = class {
213
+ /** @internal A problem found while building, reported instead of sending. */
214
+ problem;
159
215
  #filter(column, op, value) {
160
216
  if (column instanceof Col) {
161
217
  const ifNull = column.nullValue();
@@ -288,6 +344,24 @@ var Filters = class {
288
344
  return this;
289
345
  }
290
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
+ /**
291
365
  * Any of the conditions.
292
366
  *
293
367
  * @example
@@ -314,6 +388,7 @@ var Filters = class {
314
388
  #group(group, conditions) {
315
389
  const q = new FilterGroup();
316
390
  conditions(q);
391
+ if (q.problem) this.problem ??= q.problem;
317
392
  this.push({
318
393
  group,
319
394
  filters: q.filters
@@ -340,8 +415,6 @@ var FilterGroup = class extends Filters {
340
415
  var Builder = class extends Filters {
341
416
  /** @internal */ transport;
342
417
  /** @internal */ req;
343
- /** @internal A problem found while building, reported instead of sending. */
344
- problem;
345
418
  #name;
346
419
  /** @internal */
347
420
  constructor(transport, req, name, problem) {
@@ -445,11 +518,30 @@ var QueryBuilder = class extends Builder {
445
518
  * db.from('users').select('name, posts!user_id!inner(title)') // only users that have posts
446
519
  * db.from('users').select('name, posts!user_id(count)') // posts: [{ count: 2 }]
447
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'))
448
527
  */
449
- select(columns = "*", options = {}) {
528
+ select(columns = "*", ...extra) {
450
529
  this.req.columns = columns;
451
- if (options.count) this.req.count = true;
452
- if (options.head) this.req.head = true;
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;
453
545
  return this;
454
546
  }
455
547
  /**
@@ -511,6 +603,18 @@ var QueryBuilder = class extends Builder {
511
603
  return this;
512
604
  }
513
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
+ /**
514
618
  * Leave out repeated rows: `SELECT DISTINCT`.
515
619
  *
516
620
  * @example
@@ -637,9 +741,19 @@ var QueryBuilder = class extends Builder {
637
741
  * .order('active', { ascending: false }).order('age', { ascending: false }).order('name')
638
742
  * .order('meta->>score::int', { ascending: false, nullsFirst: false })
639
743
  * .order('title', { referencedTable: 'posts' }) // posts inside each user
744
+ * .order(db.raw("coalesce(last_read_time, '1970-01-01')")) // raw, main table only
640
745
  */
641
746
  order(column, options = {}) {
642
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
+ }
643
757
  this.req.order.push({
644
758
  column,
645
759
  ascending,
@@ -921,6 +1035,7 @@ function createClient(url, apiKey, options = {}) {
921
1035
  rel,
922
1036
  col,
923
1037
  now,
1038
+ raw,
924
1039
  async transaction(build) {
925
1040
  const steps = toSteps(build({
926
1041
  from: (table) => new QueryBuilder(null, table),
@@ -928,7 +1043,8 @@ function createClient(url, apiKey, options = {}) {
928
1043
  ops,
929
1044
  rel,
930
1045
  col,
931
- now
1046
+ now,
1047
+ raw
932
1048
  }));
933
1049
  if (typeof steps === "string") return fail(steps);
934
1050
  const res = await transport.post("/transaction", { steps: steps.wire });
@@ -944,14 +1060,20 @@ function createClient(url, apiKey, options = {}) {
944
1060
  }
945
1061
  // src/env.ts
946
1062
  let client;
1063
+ const proc = globalThis.process;
1064
+ /** Reads ./.env into process.env (Node 20.12+; never overrides set vars). */
1065
+ function loadDotEnv() {
1066
+ if (!proc?.loadEnvFile) return;
1067
+ try {
1068
+ proc.loadEnvFile();
1069
+ } catch {}
1070
+ }
1071
+ loadDotEnv();
947
1072
  /** The ARN_DB_URL / ARN_DB_KEY client, made on the first query (so a later dotenv still counts). */
948
1073
  function envClient() {
949
1074
  if (client) return client;
950
- const proc = globalThis.process;
951
1075
  if (!proc) throw new Error("arn-db: `db` reads ARN_DB_URL / ARN_DB_KEY from process.env; use createClient(url, key) here");
952
- if ((!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) && proc.loadEnvFile) try {
953
- proc.loadEnvFile();
954
- } catch {}
1076
+ if (!proc.env.ARN_DB_URL || !proc.env.ARN_DB_KEY) loadDotEnv();
955
1077
  const url = proc.env.ARN_DB_URL;
956
1078
  const key = proc.env.ARN_DB_KEY;
957
1079
  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)");
@@ -975,19 +1097,22 @@ const db = {
975
1097
  ops,
976
1098
  rel,
977
1099
  col,
978
- now
1100
+ now,
1101
+ raw
979
1102
  };
980
1103
  exports.Col = Col;
981
1104
  exports.FilterGroup = FilterGroup;
982
1105
  exports.Now = Now;
983
1106
  exports.Op = Op;
984
1107
  exports.QueryBuilder = QueryBuilder;
1108
+ exports.Raw = Raw;
985
1109
  exports.WriteBuilder = WriteBuilder;
986
1110
  exports.col = col;
987
1111
  exports.createClient = createClient;
988
1112
  exports.db = db;
989
1113
  exports.now = now;
990
1114
  exports.ops = ops;
1115
+ exports.raw = raw;
991
1116
  exports.rel = rel;
992
1117
 
993
1118
  //# sourceMappingURL=index.cjs.map