cloudflare-next-intl 0.8.8 → 0.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -558,9 +558,11 @@ user's Firebase ID token is used automatically.
558
558
 
559
559
  **Two differences to know about in Supabase mode:**
560
560
 
561
- - **Per-statement transactions.** Each statement in a `withUserDb` callback is
562
- its own round-trip, so it is its own implicit transaction. Multi-statement
563
- atomicity is available in connection-string mode only.
561
+ - **Per-statement transactions in `withUserDb`/`withPublicDb`.** Each
562
+ statement in one of their callbacks is its own round-trip, so it is its own
563
+ implicit transaction `.transaction()` throws there instead of running
564
+ non-atomically. Reach for `withUserTransaction`/`withPublicTransaction`
565
+ (below) when you need more than one statement to succeed or fail together.
564
566
  - **Wider SQL surface.** `cfni_exec` runs statements your app generates, so any
565
567
  role that can execute it can run arbitrary SQL *within that role's own
566
568
  privileges* — a broader surface than PostgREST's normal verbs, though still
@@ -574,8 +576,55 @@ with or without `RETURNING` (including `ON CONFLICT ... DO UPDATE` via
574
576
  writable CTEs (`with x as (update ... returning ...) select ... from x`).
575
577
  Every value round-trips through Postgres' own text representation, not JSON,
576
578
  so arrays/`numeric`/timestamps/`bytea` decode the same way they do in
577
- connection-string mode. Not supported: multi-statement transactions (each
578
- call is its own PostgREST round-trip — see above).
579
+ connection-string mode. `withUserDb`/`withPublicDb` do not run multiple
580
+ statements atomically (each call is its own PostgREST round-trip) — see
581
+ [Multi-statement transactions](#multi-statement-transactions-withusertransaction-withpublictransaction)
582
+ below for the API that does.
583
+
584
+ #### Multi-statement transactions (`withUserTransaction`/`withPublicTransaction`)
585
+
586
+ `withUserDb`/`withPublicDb` cannot provide atomicity across statements in
587
+ Supabase mode — there is no shared session for `.transaction()` to open, so
588
+ it throws there instead of silently running non-atomically. Reach for
589
+ `withUserTransaction`/`withPublicTransaction` (also from
590
+ `cloudflare-next-intl/db`) whenever a write needs more than one statement to
591
+ succeed or fail together.
592
+
593
+ They take a `build` callback that **builds** queries instead of executing
594
+ them — call `.toSQL()` on each Drizzle query and return the array, rather
595
+ than `await`ing the query directly:
596
+
597
+ ```typescript
598
+ import { withUserTransaction } from "cloudflare-next-intl/db";
599
+ import { invitations, contractorAccessGrants } from "@/shared/db/generated/schema";
600
+
601
+ const [invitationResult, grantResult] = await withUserTransaction((db) => [
602
+ db.insert(invitations).values({ email: "a@b.com" }).returning().toSQL(),
603
+ db.insert(contractorAccessGrants).values({ propertyId: 1 }).toSQL(),
604
+ ]);
605
+ ```
606
+
607
+ Every query in the array is rendered and sent to Postgres as **one**
608
+ `cfni_exec_batch` call: the function runs each statement in order inside a
609
+ single plpgsql call, which is itself an implicit transaction, so a failure on
610
+ any statement rolls back every statement that ran before it in the same
611
+ batch. `cfni_exec_batch` ships alongside `cfni_exec` in the same
612
+ `supabase/cfni_exec.sql` file (installed the same way — see
613
+ [Schema codegen](#schema-codegen-cfni-db-codegen) below) and is available
614
+ whenever `cfni_exec` is: there is no separate config flag, and
615
+ `db.supabase.rawSql: false` turns off both.
616
+
617
+ Each result is the same `{ rows, rowCount }` shape a single `cfni_exec` call
618
+ returns — decode rows the same way you would from `db.execute(sql\`...\`)`.
619
+
620
+ `await`ing a query directly inside `build` (instead of calling `.toSQL()`)
621
+ throws immediately, naming the mistake, rather than hanging or silently
622
+ running that one statement outside the batch with no atomicity.
623
+
624
+ In connection-string mode, use `withUserDb`/`withPublicDb`'s own
625
+ `.transaction()` instead — it already provides real atomicity there, so
626
+ `withUserTransaction`/`withPublicTransaction` throw rather than duplicate
627
+ that path.
579
628
 
580
629
  #### Supabase mode and REST translation
581
630
 
@@ -617,7 +666,8 @@ await withPublicDb((db) => db.delete(bonds).where(eq(bonds.id, 1)));
617
666
  | `RETURNING` clauses | Supported | Supported |
618
667
  | Operators: `=`, `<>`, `!=`, `>`, `>=`, `<`, `<=`, `like`, `ilike`, `is [not] null`, `[not] in`, `is [not] distinct from`, `~`, `~*`, `@>`, `<@`, `&&`, `>>`, `<<`, `&>`, `&<`, `-|-`, `@@` | Supported | Supported |
619
668
  | Multi-table joins, CTEs, non-count aggregates, `GROUP BY`, `UNION`, `DISTINCT`, raw SQL | Not supported by REST | Supported |
620
- | Multi-statement transactions | Not supported | Not supported (Postgres connection only) |
669
+ | Multi-statement transactions in `withUserDb`/`withPublicDb` | Not supported | Not supported (each call is its own round-trip) |
670
+ | Multi-statement transactions via `withUserTransaction`/`withPublicTransaction` | N/A — batch-only API | Supported (`cfni_exec_batch`, one round-trip) |
621
671
 
622
672
  #### Enforcing single API via ESLint (`cloudflare-next-intl/dbEslint`)
623
673
 
@@ -632,8 +682,9 @@ export default [
632
682
  ];
633
683
  ```
634
684
 
635
- Two query wrappers, both from `cloudflare-next-intl/db`. Choose by who is
636
- allowed to see the rows:
685
+ Four query wrappers, all from `cloudflare-next-intl/db`. Choose by who is
686
+ allowed to see the rows, then by whether you need more than one statement to
687
+ succeed or fail together:
637
688
 
638
689
  - `withPublicDb(fn)` — runs `fn` as the anonymous role: a pooled connection
639
690
  with no transaction/role switch in connection-string mode, or the anon key
@@ -650,6 +701,13 @@ allowed to see the rows:
650
701
  the id comes from `db.getUserId()` if set, otherwise automatically from the
651
702
  signed-in Firebase user when `firebaseAuth` is configured — you rarely need
652
703
  to pass it explicitly.
704
+ - `withPublicTransaction(build)` / `withUserTransaction(build)` — same role
705
+ split as above, but for a write that needs more than one statement to
706
+ succeed or fail together. See
707
+ [Multi-statement transactions](#multi-statement-transactions-withusertransaction-withpublictransaction)
708
+ above — `build` returns built (`.toSQL()`) queries rather than executing
709
+ them, and these two are currently Supabase-mode only (connection-string
710
+ mode already has real atomicity via `withUserDb`/`withPublicDb`).
653
711
 
654
712
  ```typescript
655
713
  // anywhere on the server
@@ -703,7 +761,9 @@ npx cfni-db-codegen --check
703
761
  | `--db-url=` | `CODEGEN_DATABASE_URL` | `postgresql://postgres:postgres@127.0.0.1:54322/postgres` |
704
762
  | `--drizzle-config=` | `CFNI_DB_DRIZZLE_CONFIG` | none |
705
763
  | `--rpc-dir=` | `CFNI_DB_RPC_DIR` | sibling of `--ddl-dir`, e.g. `supabase/rpc` |
764
+ | `--rpc-file-name=` | `CFNI_DB_RPC_FILE_NAME` | `cfni_exec.sql` |
706
765
  | `--tests-dir=` | `CFNI_DB_TESTS_DIR` | sibling of `--ddl-dir`, e.g. `supabase/tests` |
766
+ | `--tests-file-name=` | `CFNI_DB_TESTS_FILE_NAME` | `cfni_exec.sql` |
707
767
  | `--force` | `CFNI_DB_FORCE_EXEC=true` | off |
708
768
  | `--skip-exec` | `CFNI_DB_SKIP_EXEC=true` | off |
709
769
  | `--check` | — | off |
@@ -718,15 +778,21 @@ them and fails naming the first one that is stale.
718
778
  npx cfni-db-codegen --out-dir=src/shared/db/generated --out-dir=../other-app/src/db/generated
719
779
  ```
720
780
 
721
- ##### Keeping `cfni_exec.sql` in sync (`--rpc-dir`/`--tests-dir`/`--force`/`--skip-exec`)
781
+ ##### Keeping `cfni_exec.sql` in sync (`--rpc-dir`/`--rpc-file-name`/`--tests-dir`/`--tests-file-name`/`--force`/`--skip-exec`)
722
782
 
723
783
  After a successful (non-`--check`) run, `cfni-db-codegen` also copies
724
784
  `supabase/cfni_exec.sql` and its pgTAP test file (see
725
785
  [Testing `cfni_exec.sql` itself](#testing-cfni_execsql-itself) below) into
726
786
  your project — `--rpc-dir`/`--tests-dir` (defaulting to sibling folders of
727
- `--ddl-dir`, so `rpc`/`tests` next to `data-base`). This keeps a project that
728
- enables Supabase mode's raw-SQL path always holding the current version of the
729
- function, without a manual copy-paste step.
787
+ `--ddl-dir`, so `rpc`/`tests` next to `data-base`), each named `cfni_exec.sql`
788
+ by default. This keeps a project that enables Supabase mode's raw-SQL path
789
+ always holding the current version of the function, without a manual
790
+ copy-paste step. Since the file now ships both `cfni_exec` and
791
+ `cfni_exec_batch` (see
792
+ [Multi-statement transactions](#multi-statement-transactions-withusertransaction-withpublictransaction)
793
+ above), pass `--rpc-file-name=`/`--tests-file-name=` (or
794
+ `CFNI_DB_RPC_FILE_NAME`/`CFNI_DB_TESTS_FILE_NAME`) if you'd rather install it
795
+ under a name that reflects that, e.g. `cfni_exec_and_batch.sql`.
730
796
 
731
797
  This step is gated on `db.supabase.rawSql` (see
732
798
  [Supabase mode without `cfni_exec`](#supabase-mode-without-cfni_exec)):
@@ -747,7 +813,8 @@ set `CFNI_DB_SKIP_EXEC=true`) to turn this whole step off, independent of
747
813
 
748
814
  To run only this step — no `drizzle-kit pull`, no live Postgres needed — use
749
815
  the standalone `cfni-db-install-exec` binary instead, which accepts the same
750
- `--rpc-dir`/`--tests-dir`/`--force` flags:
816
+ `--rpc-dir`/`--rpc-file-name`/`--tests-dir`/`--tests-file-name`/`--force`
817
+ flags:
751
818
 
752
819
  ```bash
753
820
  npx cfni-db-install-exec
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Regenerates Drizzle models by introspecting a live Postgres with drizzle-kit.
3
3
  // Usage: cfni-db-codegen [--check] [--ddl-dir=…] [--out-dir=…] [--out-file=…] [--db-url=…] [--drizzle-config=…]
4
- // [--rpc-dir=…] [--tests-dir=…] [--force] [--skip-exec]
4
+ // [--rpc-dir=…] [--rpc-file-name=…] [--tests-dir=…] [--tests-file-name=…] [--force] [--skip-exec]
5
+ //
6
+ // --rpc-file-name/--tests-file-name (also CFNI_DB_RPC_FILE_NAME/
7
+ // CFNI_DB_TESTS_FILE_NAME) rename the installed cfni_exec.sql/its pgTAP test
8
+ // file in the consuming project — e.g. if a project prefers a name that
9
+ // reflects the file now ships both `cfni_exec` and `cfni_exec_batch`. Default
10
+ // to `cfni_exec.sql` for both, unchanged from before.
5
11
  //
6
12
  // --out-dir may be repeated, or given a comma-separated list, to generate the
7
13
  // same schema into several projects at once (CFNI_DB_OUT_DIR accepts a
@@ -4,7 +4,7 @@
4
4
  // generation, exposed standalone for when you only want this and nothing
5
5
  // else (no drizzle-kit pull, no live Postgres needed).
6
6
  //
7
- // Usage: cfni-db-install-exec [--rpc-dir=…] [--tests-dir=…] [--force]
7
+ // Usage: cfni-db-install-exec [--rpc-dir=…] [--rpc-file-name=…] [--tests-dir=…] [--tests-file-name=…] [--force]
8
8
  //
9
9
  // Gated on the project's `db.supabase.rawSql` (read from `next.config.*`'s
10
10
  // `@intl-config` alias) the same way cfni-db-codegen's step is — pass
@@ -38,6 +38,8 @@ export default function resolveCodegenPaths(argv, env, cwd) {
38
38
  const supabaseRoot = dirname(ddlDir);
39
39
  const rpcDir = abs(cwd, flag(argv, 'rpc-dir') ?? env.CFNI_DB_RPC_DIR ?? join(supabaseRoot, 'rpc'));
40
40
  const testsDir = abs(cwd, flag(argv, 'tests-dir') ?? env.CFNI_DB_TESTS_DIR ?? join(supabaseRoot, 'tests'));
41
+ const rpcFileName = flag(argv, 'rpc-file-name') ?? env.CFNI_DB_RPC_FILE_NAME ?? DEFAULT_RPC_FILE_NAME;
42
+ const testsFileName = flag(argv, 'tests-file-name') ?? env.CFNI_DB_TESTS_FILE_NAME ?? DEFAULT_TESTS_FILE_NAME;
41
43
  return {
42
44
  ddlDir,
43
45
  targets: outDirs.map((dir) => ({
@@ -54,9 +56,9 @@ export default function resolveCodegenPaths(argv, env, cwd) {
54
56
  timeoutMs: Number(env.CODEGEN_CONNECT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS,
55
57
  drizzleConfig: drizzleConfig === null ? null : abs(cwd, drizzleConfig),
56
58
  rpcDir,
57
- rpcFile: join(rpcDir, DEFAULT_RPC_FILE_NAME),
59
+ rpcFile: join(rpcDir, rpcFileName),
58
60
  testsDir,
59
- testsFile: join(testsDir, DEFAULT_TESTS_FILE_NAME),
61
+ testsFile: join(testsDir, testsFileName),
60
62
  force: argv.includes('--force') || env.CFNI_DB_FORCE_EXEC === 'true',
61
63
  skipExec: argv.includes('--skip-exec') || env.CFNI_DB_SKIP_EXEC === 'true',
62
64
  };
@@ -1,4 +1,6 @@
1
1
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import type { Query } from 'drizzle-orm';
3
+ import type { ExecResult } from './supabase_transport';
2
4
  /**
3
5
  * The Drizzle handle passed to `withPublicDb`/`withUserDb` callbacks. Use it
4
6
  * exactly like a normal Drizzle database (`db.select().from(table)`); it is
@@ -55,3 +57,45 @@ export declare function withPublicDb<T>(fn: (db: DrizzleDb) => Promise<T>): Prom
55
57
  * const mine = await withUserDb((db) => db.select().from(orders));
56
58
  */
57
59
  export declare function withUserDb<T>(fn: (db: DrizzleDb) => Promise<T>, uid?: string): Promise<T>;
60
+ /** One statement's `{rows, rowCount}` result from a `withUserTransaction`/`withPublicTransaction` batch. */
61
+ export type { ExecResult as TransactionResult } from './supabase_transport';
62
+ /**
63
+ * Runs several statements atomically as the **anonymous** role.
64
+ *
65
+ * See {@link runTransaction} for the batching mechanism. `build` must not
66
+ * execute its queries — return their `.toSQL()` form instead.
67
+ *
68
+ * @param build Returns the queries to run, in order.
69
+ * @returns One `{rows, rowCount}` result per query, in the same order.
70
+ * @throws If `db` is not set, if this isn't Supabase mode (connection-string
71
+ * mode already has real transactions via `withPublicDb`), or if any
72
+ * statement in the batch fails — the whole batch is then rolled back.
73
+ *
74
+ * @example
75
+ * const [inserted] = await withPublicTransaction((db) => [
76
+ * db.insert(logEntries).values({ event: 'visit' }).toSQL(),
77
+ * ]);
78
+ */
79
+ export declare function withPublicTransaction(build: (db: DrizzleDb) => Promise<Query[]> | Query[]): Promise<ExecResult[]>;
80
+ /**
81
+ * Runs several statements atomically as the **signed-in user**.
82
+ *
83
+ * See {@link runTransaction} for the batching mechanism and
84
+ * {@link withUserDb} for how the caller's identity is resolved. In Supabase
85
+ * mode this is the wrapper to reach for whenever a user-owned write needs
86
+ * more than one statement to succeed or fail together — `withUserDb` alone
87
+ * cannot provide that there.
88
+ *
89
+ * @param build Returns the queries to run, in order. Do not execute them —
90
+ * call `.toSQL()` on each and return the array.
91
+ * @returns One `{rows, rowCount}` result per query, in the same order.
92
+ * @throws If `db` is not set, if no access token can be resolved, if this
93
+ * isn't Supabase mode, or if any statement in the batch fails — the whole
94
+ * batch is then rolled back.
95
+ *
96
+ * @example
97
+ * const [invitation] = await withUserTransaction((db) => [
98
+ * db.insert(invitations).values({ email }).returning().toSQL(),
99
+ * ]);
100
+ */
101
+ export declare function withUserTransaction(build: (db: DrizzleDb) => Promise<Query[]> | Query[]): Promise<ExecResult[]>;
@@ -5,6 +5,7 @@ import resolveDbMode from './resolve_mode';
5
5
  import resolveSupabaseEndpoint from './supabase_config';
6
6
  import createSupabaseTransport from './supabase_transport';
7
7
  import resolveAccessToken from './access_token';
8
+ import runTransactionBatch from './transaction_batch';
8
9
  const DEFAULT_ROLE = 'authenticated';
9
10
  /**
10
11
  * Resolves the user id for `withUserDb`, trying, in order: the explicit `uid`
@@ -45,6 +46,23 @@ async function supabaseDb(supabase, bearerToken) {
45
46
  },
46
47
  });
47
48
  }
49
+ /**
50
+ * Builds a Drizzle handle with no working transport, for
51
+ * `withUserTransaction`/`withPublicTransaction` callbacks in Supabase mode.
52
+ * Query builders' `.toSQL()` never touches the session, so this is safe to
53
+ * hand out purely for building statements — but `await`ing a query directly
54
+ * (instead of collecting its `.toSQL()` output) throws immediately here
55
+ * instead of hanging or silently running outside the batch.
56
+ */
57
+ function buildOnlyDb() {
58
+ const throwIfExecuted = () => {
59
+ throw new Error('db: this Drizzle handle is for building statements only — call `.toSQL()` on each ' +
60
+ 'query and return the array, do not `await`/execute it directly. Awaiting a query ' +
61
+ 'inside a withUserTransaction/withPublicTransaction callback runs it outside the ' +
62
+ 'batch, with no atomicity, which is exactly what these wrappers exist to prevent.');
63
+ };
64
+ return new Proxy({}, { get: () => throwIfExecuted });
65
+ }
48
66
  /**
49
67
  * Runs a query as the **anonymous** role: no transaction, no role switch, no
50
68
  * user identity attached. Use this for data any visitor may read.
@@ -133,3 +151,95 @@ export async function withUserDb(fn, uid) {
133
151
  disconnectPostgres(config);
134
152
  }
135
153
  }
154
+ /**
155
+ * Runs several statements atomically, whichever transport mode is active.
156
+ *
157
+ * `build` does not execute its queries — it **builds** them and returns the
158
+ * array. Call `.toSQL()` on each Drizzle query instead of `await`ing it
159
+ * (`await`ing throws immediately here; see {@link buildOnlyDb}). This is
160
+ * only reachable in Supabase mode — connection-string mode already has real
161
+ * atomicity through `withUserDb`/`withPublicDb`'s own `.transaction()`, so
162
+ * `withUserTransaction`/`withPublicTransaction` throw there instead of
163
+ * duplicating that path. In Supabase mode, where `.transaction()` cannot
164
+ * open a session, every query is inlined and sent as one `cfni_exec_batch`
165
+ * call: the Postgres function runs them in order inside a single plpgsql
166
+ * call, which is itself an implicit transaction, so a failure on any
167
+ * statement rolls back every statement before it.
168
+ *
169
+ * @param db The already-resolved `db` config.
170
+ * @param bearerToken The anon key or user JWT.
171
+ * @param build Returns the queries to run, via `.toSQL()` — never executes them directly.
172
+ * @returns One result per query, in the same order as `build`'s array.
173
+ */
174
+ function requireSupabaseTransactionMode(db) {
175
+ if (resolveDbMode(db) !== 'supabase') {
176
+ throw new Error('db: withUserTransaction/withPublicTransaction only run their Supabase-mode batch path ' +
177
+ 'right now. In connection-string mode, use withUserDb/withPublicDb\'s own `.transaction()` ' +
178
+ '— it already provides real atomicity there.');
179
+ }
180
+ const supabase = db.supabase;
181
+ if (supabase.rawSql === false) {
182
+ throw new Error('db: withUserTransaction/withPublicTransaction need `cfni_exec_batch`, which runs through ' +
183
+ '`cfni_exec` — both are unavailable while `db.supabase.rawSql` is `false`. Install ' +
184
+ 'cfni_exec.sql and drop `rawSql: false`, or use `db.connectionString` for a direct ' +
185
+ 'Postgres connection instead.');
186
+ }
187
+ return supabase;
188
+ }
189
+ async function runTransaction(supabase, bearerToken, build) {
190
+ const queries = await build(buildOnlyDb());
191
+ const batchQueries = queries.map((query) => ({ sql: query.sql, params: query.params }));
192
+ return runTransactionBatch(supabase, bearerToken, batchQueries);
193
+ }
194
+ /**
195
+ * Runs several statements atomically as the **anonymous** role.
196
+ *
197
+ * See {@link runTransaction} for the batching mechanism. `build` must not
198
+ * execute its queries — return their `.toSQL()` form instead.
199
+ *
200
+ * @param build Returns the queries to run, in order.
201
+ * @returns One `{rows, rowCount}` result per query, in the same order.
202
+ * @throws If `db` is not set, if this isn't Supabase mode (connection-string
203
+ * mode already has real transactions via `withPublicDb`), or if any
204
+ * statement in the batch fails — the whole batch is then rolled back.
205
+ *
206
+ * @example
207
+ * const [inserted] = await withPublicTransaction((db) => [
208
+ * db.insert(logEntries).values({ event: 'visit' }).toSQL(),
209
+ * ]);
210
+ */
211
+ export async function withPublicTransaction(build) {
212
+ const db = config.db;
213
+ requireDbConfig(db);
214
+ const supabase = requireSupabaseTransactionMode(db);
215
+ const { anonKey } = await resolveSupabaseEndpoint(supabase);
216
+ return runTransaction(supabase, anonKey, build);
217
+ }
218
+ /**
219
+ * Runs several statements atomically as the **signed-in user**.
220
+ *
221
+ * See {@link runTransaction} for the batching mechanism and
222
+ * {@link withUserDb} for how the caller's identity is resolved. In Supabase
223
+ * mode this is the wrapper to reach for whenever a user-owned write needs
224
+ * more than one statement to succeed or fail together — `withUserDb` alone
225
+ * cannot provide that there.
226
+ *
227
+ * @param build Returns the queries to run, in order. Do not execute them —
228
+ * call `.toSQL()` on each and return the array.
229
+ * @returns One `{rows, rowCount}` result per query, in the same order.
230
+ * @throws If `db` is not set, if no access token can be resolved, if this
231
+ * isn't Supabase mode, or if any statement in the batch fails — the whole
232
+ * batch is then rolled back.
233
+ *
234
+ * @example
235
+ * const [invitation] = await withUserTransaction((db) => [
236
+ * db.insert(invitations).values({ email }).returning().toSQL(),
237
+ * ]);
238
+ */
239
+ export async function withUserTransaction(build) {
240
+ const db = config.db;
241
+ requireDbConfig(db);
242
+ const supabase = requireSupabaseTransactionMode(db);
243
+ const token = await resolveAccessToken(config);
244
+ return runTransaction(supabase, token, build);
245
+ }
@@ -7,6 +7,11 @@
7
7
  * - {@link withPublicDb} — anonymous role, for data any visitor may read.
8
8
  * - {@link withUserDb} — the signed-in user, with RLS applied to their id.
9
9
  *
10
+ * Need more than one statement to succeed or fail together?
11
+ * - {@link withPublicTransaction} / {@link withUserTransaction} — build
12
+ * queries with `.toSQL()` instead of executing them; every statement then
13
+ * runs atomically, whichever transport mode is active.
14
+ *
10
15
  * Two transports reach Postgres behind that same Drizzle query API, chosen by
11
16
  * `resolveDbMode` from which `db` config fields are set: `connectionString`
12
17
  * for a direct connection (wins if both are configured), or `supabase` for
@@ -18,12 +23,14 @@
18
23
  * statement is first translated into `@supabase/supabase-js` `.from()` calls;
19
24
  * anything PostgREST cannot express falls back to `cfni_exec`, and if
20
25
  * `db.supabase.rawSql` is `false` the call throws naming the construct that
21
- * needs raw SQL. `.transaction()` is never available in Supabase mode.
26
+ * needs raw SQL. `withPublicDb`/`withUserDb`'s `.transaction()` is never
27
+ * available in Supabase mode — reach for `withPublicTransaction`/
28
+ * `withUserTransaction` there instead.
22
29
  *
23
30
  * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
24
31
  * the separate `cloudflare-next-intl/dbHelpers` entry point.
25
32
  */
26
- export { withPublicDb, withUserDb } from './context';
27
- export type { DrizzleDb } from './context';
33
+ export { withPublicDb, withUserDb, withPublicTransaction, withUserTransaction } from './context';
34
+ export type { DrizzleDb, TransactionResult } from './context';
28
35
  export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
29
36
  export type { DbRoutingConfig } from '../types/types';
@@ -7,6 +7,11 @@
7
7
  * - {@link withPublicDb} — anonymous role, for data any visitor may read.
8
8
  * - {@link withUserDb} — the signed-in user, with RLS applied to their id.
9
9
  *
10
+ * Need more than one statement to succeed or fail together?
11
+ * - {@link withPublicTransaction} / {@link withUserTransaction} — build
12
+ * queries with `.toSQL()` instead of executing them; every statement then
13
+ * runs atomically, whichever transport mode is active.
14
+ *
10
15
  * Two transports reach Postgres behind that same Drizzle query API, chosen by
11
16
  * `resolveDbMode` from which `db` config fields are set: `connectionString`
12
17
  * for a direct connection (wins if both are configured), or `supabase` for
@@ -18,10 +23,12 @@
18
23
  * statement is first translated into `@supabase/supabase-js` `.from()` calls;
19
24
  * anything PostgREST cannot express falls back to `cfni_exec`, and if
20
25
  * `db.supabase.rawSql` is `false` the call throws naming the construct that
21
- * needs raw SQL. `.transaction()` is never available in Supabase mode.
26
+ * needs raw SQL. `withPublicDb`/`withUserDb`'s `.transaction()` is never
27
+ * available in Supabase mode — reach for `withPublicTransaction`/
28
+ * `withUserTransaction` there instead.
22
29
  *
23
30
  * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
24
31
  * the separate `cloudflare-next-intl/dbHelpers` entry point.
25
32
  */
26
- export { withPublicDb, withUserDb } from './context';
33
+ export { withPublicDb, withUserDb, withPublicTransaction, withUserTransaction } from './context';
27
34
  export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
@@ -1,4 +1,24 @@
1
1
  import type { SupabaseDbConfig } from '../types/types';
2
+ export interface SupabaseRpcError {
3
+ message: string;
4
+ code?: string;
5
+ }
6
+ export interface ExecResult {
7
+ rows: unknown[];
8
+ rowCount: number | null;
9
+ }
10
+ /**
11
+ * `cfni_exec` returns each row as a Postgres composite-literal string (see
12
+ * {@link parseComposite}), so the JSON-decoded `data.rows` array here is a
13
+ * `string[]`, not already the `(string | null)[][]` `pg-proxy` expects —
14
+ * that positional-array shape is what this reconstructs.
15
+ *
16
+ * Exported for {@link ../transaction_batch}, which decodes each element of
17
+ * `cfni_exec_batch`'s result array the same way this decodes a single
18
+ * `cfni_exec` result — the two functions return one `{rows, rowCount}` shape
19
+ * per statement either way.
20
+ */
21
+ export declare function parseExecResult(data: unknown): ExecResult;
2
22
  /**
3
23
  * The executor shape `drizzle-orm/pg-proxy` calls with each generated
4
24
  * statement. Declared structurally so this file never imports `drizzle-orm`.
@@ -30,3 +50,8 @@ export type SupabaseRemoteCallback = (sql: string, params: unknown[], method: 'a
30
50
  * @returns A callback suitable for `drizzle-orm/pg-proxy`'s `drizzle()`.
31
51
  */
32
52
  export default function createSupabaseTransport(supabase: SupabaseDbConfig, bearerToken: string): SupabaseRemoteCallback;
53
+ /**
54
+ * Exported for {@link ../transaction_batch}, which reports `cfni_exec_batch`
55
+ * RPC failures the same way this reports `cfni_exec` failures.
56
+ */
57
+ export declare function describeFailure(error: SupabaseRpcError, execFunction: string): string;
@@ -10,8 +10,13 @@ const DEFAULT_EXEC_FUNCTION = 'cfni_exec';
10
10
  * {@link parseComposite}), so the JSON-decoded `data.rows` array here is a
11
11
  * `string[]`, not already the `(string | null)[][]` `pg-proxy` expects —
12
12
  * that positional-array shape is what this reconstructs.
13
+ *
14
+ * Exported for {@link ../transaction_batch}, which decodes each element of
15
+ * `cfni_exec_batch`'s result array the same way this decodes a single
16
+ * `cfni_exec` result — the two functions return one `{rows, rowCount}` shape
17
+ * per statement either way.
13
18
  */
14
- function parseExecResult(data) {
19
+ export function parseExecResult(data) {
15
20
  if (Array.isArray(data))
16
21
  return { rows: data.map(parseRow), rowCount: null };
17
22
  if (data && typeof data === 'object' && 'rows' in data) {
@@ -77,7 +82,11 @@ function unsupportedMessage(error, execFunction) {
77
82
  `Install the ${execFunction} function from supabase/cfni_exec.sql and drop \`rawSql: false\`, ` +
78
83
  'or use `db.connectionString` for a direct Postgres connection.');
79
84
  }
80
- function describeFailure(error, execFunction) {
85
+ /**
86
+ * Exported for {@link ../transaction_batch}, which reports `cfni_exec_batch`
87
+ * RPC failures the same way this reports `cfni_exec` failures.
88
+ */
89
+ export function describeFailure(error, execFunction) {
81
90
  // PGRST202 is PostgREST's "no such function" — by far the most likely
82
91
  // first-run failure, so point at the install step instead of the raw code.
83
92
  if (error.code === 'PGRST202') {
@@ -0,0 +1,31 @@
1
+ import type { SupabaseDbConfig } from '../types/types';
2
+ import { type ExecResult } from './supabase_transport';
3
+ /** One statement to run inside a batch: Drizzle's `.toSQL()` output. */
4
+ export interface BatchQuery {
5
+ sql: string;
6
+ params: unknown[];
7
+ }
8
+ /**
9
+ * Sends every query in `queries` to `cfni_exec_batch` as one PostgREST round
10
+ * trip. The Postgres function runs them in order inside a single plpgsql
11
+ * call — itself an implicit transaction — so a failure on any statement
12
+ * rolls back every statement that ran before it in the same batch, giving
13
+ * Supabase-mode callers the atomicity `.transaction()` cannot provide there
14
+ * (see `context.ts`'s `supabaseDb`).
15
+ *
16
+ * Each query is rendered with {@link inlineParams} exactly like a normal
17
+ * `cfni_exec` call, since `cfni_exec_batch` takes pre-rendered statement
18
+ * text the same way `cfni_exec` does — neither function binds parameters
19
+ * itself.
20
+ *
21
+ * @param supabase The `db.supabase` config block.
22
+ * @param bearerToken Token resolved as the caller's identity — the anon key
23
+ * for {@link ../context.withPublicTransaction}, a user JWT for
24
+ * {@link ../context.withUserTransaction}.
25
+ * @param queries The statements to run, in order. An empty array is a no-op
26
+ * that still makes the round trip, matching `cfni_exec_batch(array[]::text[])`.
27
+ * @returns One `{rows, rowCount}` result per query, in the same order.
28
+ * @throws If the batch RPC itself fails to reach Postgres, or if any
29
+ * statement in the batch fails — the whole batch is rolled back either way.
30
+ */
31
+ export default function runTransactionBatch(supabase: SupabaseDbConfig, bearerToken: string, queries: BatchQuery[]): Promise<ExecResult[]>;
@@ -0,0 +1,45 @@
1
+ import createRestClient from './rest_client';
2
+ import inlineParams from './inline_params';
3
+ import { parseExecResult, describeFailure } from './supabase_transport';
4
+ /**
5
+ * Fixed, unlike `cfni_exec`'s `execFunction` — `cfni_exec_batch` ships in the
6
+ * same `supabase/cfni_exec.sql` file and is always available whenever
7
+ * `cfni_exec` is (there is no separate config for it; `rawSql: false` turns
8
+ * off both, checked by `context.ts` before this is ever called).
9
+ */
10
+ const BATCH_FUNCTION = 'cfni_exec_batch';
11
+ /**
12
+ * Sends every query in `queries` to `cfni_exec_batch` as one PostgREST round
13
+ * trip. The Postgres function runs them in order inside a single plpgsql
14
+ * call — itself an implicit transaction — so a failure on any statement
15
+ * rolls back every statement that ran before it in the same batch, giving
16
+ * Supabase-mode callers the atomicity `.transaction()` cannot provide there
17
+ * (see `context.ts`'s `supabaseDb`).
18
+ *
19
+ * Each query is rendered with {@link inlineParams} exactly like a normal
20
+ * `cfni_exec` call, since `cfni_exec_batch` takes pre-rendered statement
21
+ * text the same way `cfni_exec` does — neither function binds parameters
22
+ * itself.
23
+ *
24
+ * @param supabase The `db.supabase` config block.
25
+ * @param bearerToken Token resolved as the caller's identity — the anon key
26
+ * for {@link ../context.withPublicTransaction}, a user JWT for
27
+ * {@link ../context.withUserTransaction}.
28
+ * @param queries The statements to run, in order. An empty array is a no-op
29
+ * that still makes the round trip, matching `cfni_exec_batch(array[]::text[])`.
30
+ * @returns One `{rows, rowCount}` result per query, in the same order.
31
+ * @throws If the batch RPC itself fails to reach Postgres, or if any
32
+ * statement in the batch fails — the whole batch is rolled back either way.
33
+ */
34
+ export default async function runTransactionBatch(supabase, bearerToken, queries) {
35
+ const getClient = createRestClient(supabase, bearerToken);
36
+ const client = await getClient();
37
+ const statements = queries.map((query) => inlineParams(query.sql, query.params));
38
+ const { data, error } = await client.rpc(BATCH_FUNCTION, { statements });
39
+ if (error)
40
+ throw new Error(describeFailure(error, BATCH_FUNCTION));
41
+ if (!Array.isArray(data)) {
42
+ throw new Error(`db: ${BATCH_FUNCTION} returned a non-array result — is it installed from the version of supabase/cfni_exec.sql shipped with this package?`);
43
+ }
44
+ return data.map(parseExecResult);
45
+ }
@@ -850,6 +850,12 @@ export interface SupabaseDbConfig {
850
850
  * conflict`, `returning`) and throw for anything else — joins,
851
851
  * aggregates, CTEs, transactions — naming the construct that needs raw
852
852
  * SQL. Defaults to `true`.
853
+ *
854
+ * Also gates `withUserTransaction`/`withPublicTransaction`: their
855
+ * `cfni_exec_batch` function ships in the same `supabase/cfni_exec.sql`
856
+ * file and needs `cfni_exec` itself to run each statement, so batching
857
+ * is on whenever this is (there is no separate flag for it) and throws
858
+ * the same install-or-use-`connectionString` error when this is `false`.
853
859
  */
854
860
  rawSql?: boolean;
855
861
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -139,12 +139,53 @@ begin
139
139
  end;
140
140
  $$;
141
141
 
142
+ -- Runs several statements as one atomic unit: `execute`d one at a time via
143
+ -- `cfni_exec`, all inside this single function body, so a failure on any
144
+ -- statement rolls back everything the batch already did — a plpgsql function
145
+ -- call is itself an implicit transaction, which is what makes this atomic
146
+ -- even though `pg-proxy` (the Supabase-mode transport) can open no
147
+ -- transaction of its own: each statement it sends is normally its own
148
+ -- independent PostgREST round-trip with no shared session. This is the
149
+ -- explicit batch API for that gap — see `withUserTransaction`/
150
+ -- `withPublicTransaction` on the TypeScript side, which render every query
151
+ -- in the callback with `.toSQL()` up front (nothing executes until the
152
+ -- batch is sent) and pass the resulting array here as one call.
153
+ --
154
+ -- Returns a jsonb array with one `cfni_exec`-shaped `{rows, rowCount}` result
155
+ -- per input statement, in the same order — so the caller can read each
156
+ -- statement's own result the way it would outside a batch.
157
+ --
158
+ -- SECURITY INVOKER for the same reason as `cfni_exec`: the whole batch runs
159
+ -- with the caller's own privileges, so RLS applies per statement exactly as
160
+ -- it would if each ran on its own.
161
+ create or replace function public.cfni_exec_batch(statements text[])
162
+ returns jsonb
163
+ language plpgsql
164
+ security invoker
165
+ set search_path = public
166
+ as $$
167
+ declare
168
+ results jsonb := '[]'::jsonb;
169
+ statement text;
170
+ begin
171
+ foreach statement in array statements loop
172
+ results := results || jsonb_build_array(public.cfni_exec(statement));
173
+ end loop;
174
+ return results;
175
+ end;
176
+ $$;
177
+
142
178
  revoke all on function public.cfni_exec(text) from public;
143
179
  grant execute on function public.cfni_exec(text) to authenticated;
144
180
  -- Grant to anon only if your app calls withPublicDb:
145
181
  grant execute on function public.cfni_exec(text) to anon;
146
182
  grant execute on function public.cfni_exec(text) to service_role;
147
183
 
184
+ revoke all on function public.cfni_exec_batch(text[]) from public;
185
+ grant execute on function public.cfni_exec_batch(text[]) to authenticated;
186
+ grant execute on function public.cfni_exec_batch(text[]) to anon;
187
+ grant execute on function public.cfni_exec_batch(text[]) to service_role;
188
+
148
189
  revoke all on function public.cfni_top_level_verb(text) from public;
149
190
  grant execute on function public.cfni_top_level_verb(text) to authenticated;
150
191
  grant execute on function public.cfni_top_level_verb(text) to anon;
@@ -5,7 +5,7 @@
5
5
  -- checks the SQL function itself and its RLS interaction directly in
6
6
  -- Postgres, without going through the transport/JS parsing layer.
7
7
  begin;
8
- select plan(20);
8
+ select plan(26);
9
9
 
10
10
  do $$
11
11
  begin
@@ -146,5 +146,48 @@ select is(
146
146
  );
147
147
  reset role;
148
148
 
149
+ -- cfni_exec_batch
150
+
151
+ select is(
152
+ cfni_exec_batch(array[
153
+ 'insert into cfni_test_a (id, name) values (30, ''batch1'') returning id, name',
154
+ 'insert into cfni_test_a (id, name) values (31, ''batch2'') returning id, name'
155
+ ]),
156
+ '[{"rows": ["(30,batch1)"], "rowCount": 1}, {"rows": ["(31,batch2)"], "rowCount": 1}]'::jsonb,
157
+ 'batch runs every statement and returns one cfni_exec-shaped result per statement, in order'
158
+ );
159
+
160
+ select is(
161
+ (select count(*) from cfni_test_a where id in (30, 31)),
162
+ 2::bigint,
163
+ 'both batched inserts committed'
164
+ );
165
+
166
+ select throws_ok(
167
+ $$select cfni_exec_batch(array[
168
+ 'insert into cfni_test_a (id, name) values (40, ''ok'')',
169
+ 'insert into cfni_test_a (id, name) values (invalid syntax'
170
+ ])$$,
171
+ null, null,
172
+ 'a later statement failing raises out of the batch call'
173
+ );
174
+
175
+ select is(
176
+ (select count(*) from cfni_test_a where id = 40),
177
+ 0::bigint,
178
+ 'an earlier statement in a failed batch is rolled back — the batch is atomic'
179
+ );
180
+
181
+ select is(pg_proc.prosecdef, false, 'cfni_exec_batch is security invoker, not security definer')
182
+ from pg_proc where proname = 'cfni_exec_batch';
183
+
184
+ set role anon;
185
+ select is(
186
+ cfni_exec_batch(array['select * from cfni_test_a where id = 30']),
187
+ '[{"rows": [], "rowCount": 0}]'::jsonb,
188
+ 'cfni_exec_batch applies RLS per statement just like cfni_exec — anon sees no rows here'
189
+ );
190
+ reset role;
191
+
149
192
  select * from finish();
150
193
  rollback;