cloudflare-next-intl 0.8.7 → 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 +80 -13
- package/bin/db_codegen.mjs +7 -1
- package/bin/db_install_exec.mjs +1 -1
- package/dist/src/db/codegen_paths.js +4 -2
- package/dist/src/db/context.d.ts +44 -0
- package/dist/src/db/context.js +110 -0
- package/dist/src/db/index.d.ts +10 -3
- package/dist/src/db/index.js +9 -2
- package/dist/src/db/supabase_transport.d.ts +25 -0
- package/dist/src/db/supabase_transport.js +11 -2
- package/dist/src/db/transaction_batch.d.ts +31 -0
- package/dist/src/db/transaction_batch.js +45 -0
- package/dist/src/types/types.d.ts +6 -0
- package/package.json +1 -1
- package/supabase/cfni_exec.sql +43 -0
- package/supabase/tests/cfni_exec.sql +44 -1
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
|
|
562
|
-
its own round-trip, so it is its own
|
|
563
|
-
|
|
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.
|
|
578
|
-
call is its own PostgREST round-trip — see
|
|
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 (
|
|
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
|
-
|
|
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`)
|
|
728
|
-
enables Supabase mode's raw-SQL path
|
|
729
|
-
function, without a manual
|
|
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`
|
|
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
|
package/bin/db_codegen.mjs
CHANGED
|
@@ -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
|
package/bin/db_install_exec.mjs
CHANGED
|
@@ -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,
|
|
59
|
+
rpcFile: join(rpcDir, rpcFileName),
|
|
58
60
|
testsDir,
|
|
59
|
-
testsFile: join(testsDir,
|
|
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
|
};
|
package/dist/src/db/context.d.ts
CHANGED
|
@@ -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[]>;
|
package/dist/src/db/context.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/db/index.d.ts
CHANGED
|
@@ -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
|
|
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';
|
package/dist/src/db/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
package/supabase/cfni_exec.sql
CHANGED
|
@@ -34,6 +34,7 @@ create or replace function public.cfni_exec(statement text)
|
|
|
34
34
|
returns jsonb
|
|
35
35
|
language plpgsql
|
|
36
36
|
security invoker
|
|
37
|
+
set search_path = public
|
|
37
38
|
as $$
|
|
38
39
|
declare
|
|
39
40
|
top_level_verb text;
|
|
@@ -102,6 +103,7 @@ create or replace function public.cfni_top_level_verb(statement text)
|
|
|
102
103
|
returns text
|
|
103
104
|
language plpgsql
|
|
104
105
|
immutable
|
|
106
|
+
set search_path = ''
|
|
105
107
|
as $$
|
|
106
108
|
declare
|
|
107
109
|
stripped text;
|
|
@@ -137,12 +139,53 @@ begin
|
|
|
137
139
|
end;
|
|
138
140
|
$$;
|
|
139
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
|
+
|
|
140
178
|
revoke all on function public.cfni_exec(text) from public;
|
|
141
179
|
grant execute on function public.cfni_exec(text) to authenticated;
|
|
142
180
|
-- Grant to anon only if your app calls withPublicDb:
|
|
143
181
|
grant execute on function public.cfni_exec(text) to anon;
|
|
144
182
|
grant execute on function public.cfni_exec(text) to service_role;
|
|
145
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
|
+
|
|
146
189
|
revoke all on function public.cfni_top_level_verb(text) from public;
|
|
147
190
|
grant execute on function public.cfni_top_level_verb(text) to authenticated;
|
|
148
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(
|
|
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;
|