cloudflare-next-intl 0.10.18 → 0.10.19

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
@@ -914,7 +914,7 @@ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
914
914
  ```
915
915
 
916
916
  Supabase mode requires one function in your database, shipped at
917
- `node_modules/cloudflare-next-intl/supabase/cfni_exec.sql`. Run it once (via
917
+ `node_modules/cloudflare-next-intl-db-codegen/supabase/cfni_exec.sql` (installed as a dependency). Run it once (via
918
918
  `supabase db push`, a migration, or the SQL editor) — the file starts with a
919
919
  `drop function if exists` so re-running it to upgrade is always safe. It is
920
920
  `security invoker`, so statements execute with the caller's own privileges and
@@ -1248,7 +1248,7 @@ tested two ways in this package's own repo, and both are shipped so you can
1248
1248
  reuse them against your own database rather than trusting the function
1249
1249
  untested:
1250
1250
 
1251
- - `supabase/tests/cfni_exec.sql` — a [pgTAP](https://pgtap.org/) suite,
1251
+ - `node_modules/cloudflare-next-intl-db-codegen/supabase/tests/cfni_exec.sql` — a [pgTAP](https://pgtap.org/) suite,
1252
1252
  runnable with `supabase test db` (or `pg_prove`) once both this file and
1253
1253
  `cfni_exec.sql` are installed in a database. It checks the SQL function
1254
1254
  directly: every statement shape `cfni_exec` classifies (plain `SELECT`,
package/llms.txt CHANGED
@@ -73,7 +73,7 @@ Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@sup
73
73
  - Direct Postgres (wins if configured): `db.connectionString` — Postgres connection string, or a sync/async function returning one (resolved on each connect). The function form is the way to read a value unavailable at module scope, e.g. a Cloudflare Hyperdrive binding: `connectionString: async () => (await getCloudflareContext({ async: true })).env.HYPERDRIVE.connectionString`. There is no separate `hyperdriveBinding` option.
74
74
  - `db.autoHyperdrive` — when `true` (default) and `db.connectionString` is unset, `env.HYPERDRIVE.connectionString` is read automatically before falling through to `db.supabase`. Set `false` to skip this and go straight to `supabase` (or the "no connection string" error).
75
75
  - `db.autoHyperdriveSkipUrls` — connection strings from `env.HYPERDRIVE.connectionString` treated as "no connection" by auto-Hyperdrive (e.g. `wrangler dev`'s unconfigured placeholder). Defaults to `['postgresql://user:pass@localhost:5432/db']`.
76
- - Supabase Data API (used only when neither of the above is set): `db.supabase` — `{ url?, anonKey?, execFunction?, rawSql? }` where `url`/`anonKey` each accept a string or a sync/async function returning one, defaulting `url`/`anonKey` to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. Statements are translated to PostgREST REST calls first; unsupported statements fall back to `supabase/cfni_exec.sql` (a `security invoker` SQL-exec function) in your database — `cfni-db-codegen`/`cfni-db-install-exec` can install it for you (see below). No multi-statement transactions — each statement in a `withUserDb` callback is its own round-trip; `.transaction()` throws instead of running non-atomically. `rawSql: false` disables `cfni_exec` fallback, throwing an informative error when a query cannot be served over REST.
76
+ - Supabase Data API (used only when neither of the above is set): `db.supabase` — `{ url?, anonKey?, execFunction?, rawSql? }` where `url`/`anonKey` each accept a string or a sync/async function returning one, defaulting `url`/`anonKey` to `NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_ANON_KEY`. Statements are translated to PostgREST REST calls first; unsupported statements fall back to `cfni_exec` (shipped as `cloudflare-next-intl-db-codegen/supabase/cfni_exec.sql`, a `security invoker` SQL-exec function) in your database — `cfni-db-codegen`/`cfni-db-install-exec` can install it for you (see below). No multi-statement transactions — each statement in a `withUserDb` callback is its own round-trip; `.transaction()` throws instead of running non-atomically. `rawSql: false` disables `cfni_exec` fallback, throwing an informative error when a query cannot be served over REST.
77
77
  - `db.disconnectAfterRequest` — deprecated, ignored since 0.8.23. Each `withPublicDb`/`withUserDb` call opens and closes its own client; Hyperdrive pools the server-side connection.
78
78
  - `db.authenticatedRole` — direct-Postgres mode only: Postgres role assumed inside `withUserDb`'s transaction. Accepts a string or a sync/async function returning one. Defaults to `'authenticated'` (Supabase RLS convention).
79
79
  - `db.authenticatedRoleClaim` — direct-Postgres mode only: Firebase custom claim field (default `'role'`) read to determine the Postgres role in `withUserDb` when `firebaseAuth` is enabled. Set `false` to disable.
@@ -83,7 +83,7 @@ Two transports, picked by which `db` fields are set — `pg`/`drizzle-orm`/`@sup
83
83
  - `withPublicDb(fn)` — anonymous role. Direct-Postgres mode: the request's pooled connection, no transaction, no role switch. Supabase mode: the anon key as the PostgREST bearer token. Either way, no user id is attached — RLS keyed on `auth.jwt()` denies access.
84
84
  - `withUserDb(fn, uid?)` — signed-in-user role. Direct-Postgres mode: a transaction with `set_config('request.jwt.claims', ...)` + `set local role`, `uid` resolution order explicit arg → `db.getUserId()` → Firebase auth uid → throws. Supabase mode: identity rides on the JWT from `db.getAccessToken`/Firebase instead (`uid` param is ignored), no transaction wraps the call.
85
85
  - `./dbHelpers` functions are plain Drizzle `sql`-building utilities with no config dependency — usable standalone.
86
- - `cfni-db-codegen` binary — regenerates Drizzle models via `drizzle-kit pull`. By default, prefers `embedded-postgres` (zero setup, no Docker needed) to load DDL from `--ddl-dir` and introspect it. If an explicit `--db-url`/`CODEGEN_DATABASE_URL` is set, connects to that live DB (and falls back to `embedded-postgres` with a warning if unreachable). Flags/env: `--ddl-dir`/`CFNI_DB_DDL_DIR`, `--out-dir`/`CFNI_DB_OUT_DIR`, `--out-file`/`CFNI_DB_OUT_FILE`, `--db-url`/`CODEGEN_DATABASE_URL`, `--drizzle-config`/`CFNI_DB_DRIZZLE_CONFIG`, `--rpc-dir`/`CFNI_DB_RPC_DIR`, `--tests-dir`/`CFNI_DB_TESTS_DIR`, `--force`/`CFNI_DB_FORCE_EXEC`, `--skip-exec`/`CFNI_DB_SKIP_EXEC`, `--check`. `--out-dir` is repeatable and accepts a comma-separated list, so one run generates the same schema (and `manifest.json`) into several projects; `--check` verifies every target. After a successful run it also copies `supabase/cfni_exec.sql` + its pgTAP test file into `--rpc-dir`/`--tests-dir` (siblings of `--ddl-dir` by default), gated on `db.supabase.rawSql` (read from `next.config.*`'s `@intl-config` alias; unknown → warns and assumes `true`) — skips if `rawSql: false`, skips if `--skip-exec`, skips-with-warning on a differing existing file unless `--force`. `cfni-db-install-exec` runs only this copy step, same flags, no Postgres/drizzle-kit needed.
86
+ - `cfni-db-codegen` binary — regenerates Drizzle models via `drizzle-kit pull`. By default, prefers `embedded-postgres` (zero setup, no Docker needed) to load DDL from `--ddl-dir` and introspect it. If an explicit `--db-url`/`CODEGEN_DATABASE_URL` is set, connects to that live DB (and falls back to `embedded-postgres` with a warning if unreachable). Flags/env: `--ddl-dir`/`CFNI_DB_DDL_DIR`, `--out-dir`/`CFNI_DB_OUT_DIR`, `--out-file`/`CFNI_DB_OUT_FILE`, `--db-url`/`CODEGEN_DATABASE_URL`, `--drizzle-config`/`CFNI_DB_DRIZZLE_CONFIG`, `--rpc-dir`/`CFNI_DB_RPC_DIR`, `--tests-dir`/`CFNI_DB_TESTS_DIR`, `--force`/`CFNI_DB_FORCE_EXEC`, `--skip-exec`/`CFNI_DB_SKIP_EXEC`, `--check`. `--out-dir` is repeatable and accepts a comma-separated list, so one run generates the same schema (and `manifest.json`) into several projects; `--check` verifies every target. After a successful run it also copies `cloudflare-next-intl-db-codegen/supabase/cfni_exec.sql` + its pgTAP test file into `--rpc-dir`/`--tests-dir` (siblings of `--ddl-dir` by default), gated on `db.supabase.rawSql` (read from `next.config.*`'s `@intl-config` alias; unknown → warns and assumes `true`) — skips if `rawSql: false`, skips if `--skip-exec`, skips-with-warning on a differing existing file unless `--force`. `cfni-db-install-exec` runs only this copy step, same flags, no Postgres/drizzle-kit needed.
87
87
 
88
88
  ```typescript
89
89
  // src/i18n/intl_config.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.10.18",
3
+ "version": "0.10.19",
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",
@@ -358,8 +358,8 @@
358
358
  "homepage": "https://github.com/demian-ilnytskyi/cloudflare-next-intl#readme",
359
359
  "dependencies": {
360
360
  "@microsoft/clarity": "^1.0.2",
361
- "cloudflare-next-intl-db": "^0.2.3",
362
- "cloudflare-next-intl-db-codegen": "^0.1.2",
361
+ "cloudflare-next-intl-db": "^0.3.0",
362
+ "cloudflare-next-intl-db-codegen": "^0.1.3",
363
363
  "jose": "^6.2.8",
364
364
  "sharp": "^0.34.5 || ^0.35.0"
365
365
  },
@@ -1,192 +0,0 @@
1
- -- Executes a statement generated by cloudflare-next-intl's `db` module and
2
- -- returns its rows/rowCount as jsonb, in the shape drizzle-orm/pg-proxy expects.
3
- --
4
- -- v2: statements arrive with parameters already inlined as literals by the
5
- -- `db` transport (see `inline_params.ts`), so this function takes no
6
- -- parameters and never binds anything itself. It also now supports
7
- -- INSERT/UPDATE/DELETE, with or without RETURNING.
8
- --
9
- -- Each output row is cast with `r::text`, Postgres' own composite-literal
10
- -- text format: `(field1,field2,...)`, fields comma-separated, quoted with
11
- -- `"…"` (doubling embedded quotes) whenever a field's own text contains a
12
- -- comma/quote/paren/backslash/whitespace, and empty-unquoted for NULL. Every
13
- -- field is already in that column's normal `::text` form — arrays as
14
- -- `{1,2}`, booleans as `t`/`f`, timestamps without a `T`, full `numeric`
15
- -- precision — the same representation the `pg` wire protocol sends in text
16
- -- mode, and what Drizzle's column mappers decode. Unlike `row_to_json`/
17
- -- `hstore(record)`, this stays correct with duplicate column names (a common
18
- -- shape after a join with `select a.*, b.*`), since it is purely positional
19
- -- and never keys fields by name. The transport (`supabase_transport.ts`)
20
- -- parses this literal back into a positional array with `parse_composite.ts`.
21
- --
22
- -- SECURITY INVOKER is load-bearing: the statement runs with the privileges of
23
- -- the caller (anon or authenticated, per the request's JWT), so row-level
24
- -- security still applies exactly as it does over the REST API. Do not change
25
- -- this to SECURITY DEFINER, and do not add a role parameter — either would
26
- -- turn this into a privilege-escalation primitive.
27
- --
28
- -- The drop below is only needed when upgrading from the old `json`-returning
29
- -- v1 of this function — `create or replace` cannot change a function's
30
- -- return type, and is a no-op (safe to re-run) when this is a fresh install.
31
- drop function if exists public.cfni_exec(text);
32
-
33
- create or replace function public.cfni_exec(statement text)
34
- returns jsonb
35
- language plpgsql
36
- security invoker
37
- set search_path = public
38
- as $$
39
- declare
40
- top_level_verb text;
41
- has_returning boolean;
42
- has_inner_writable_cte boolean;
43
- rows_json jsonb := '[]'::jsonb;
44
- row_count bigint := 0;
45
- row_value record;
46
- row_texts text[] := '{}';
47
- begin
48
- top_level_verb := public.cfni_top_level_verb(statement);
49
- has_returning := statement ~* '\yreturning\y';
50
-
51
- if top_level_verb in ('insert', 'update', 'delete') and not has_returning then
52
- execute statement;
53
- get diagnostics row_count = row_count;
54
- return jsonb_build_object('rows', '[]'::jsonb, 'rowCount', row_count);
55
- end if;
56
-
57
- if top_level_verb in ('insert', 'update', 'delete') then
58
- -- INSERT/UPDATE/DELETE with RETURNING cannot be wrapped as
59
- -- `from (<stmt>) r` — Postgres rejects DML directly inside FROM —
60
- -- so run it as a CTE instead and select its rows out of that.
61
- execute format(
62
- 'with cfni_dml_result as (%s) select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from cfni_dml_result r',
63
- statement
64
- ) into rows_json, row_count;
65
- return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
66
- end if;
67
-
68
- -- Top-level statement is a SELECT (plain, or `with ... select ...`).
69
- has_inner_writable_cte := statement ~* '\y(with|,)\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s*\(\s*(insert|update|delete)\y';
70
- if has_inner_writable_cte then
71
- -- Postgres forbids nesting a WITH that contains a data-modifying
72
- -- statement inside another query (`from (with u as (update ...
73
- -- returning ...) select ... from u) r` is rejected even though the
74
- -- statement is legal standalone) — so this shape must run exactly
75
- -- as given, not wrapped in a further subquery. Row values are
76
- -- collected by iterating the statement's own result set instead,
77
- -- casting each row to its composite-literal text form directly.
78
- for row_value in execute statement loop
79
- row_texts := row_texts || (row_value::text);
80
- row_count := row_count + 1;
81
- end loop;
82
- return jsonb_build_object('rows', to_jsonb(row_texts), 'rowCount', row_count);
83
- end if;
84
-
85
- -- Plain SELECT with no inner writable CTE: wrap as a subquery and emit
86
- -- each row's composite-literal text form; the transport parses it back
87
- -- into a positional array of column values.
88
- execute format(
89
- 'select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from (%s) r',
90
- statement
91
- ) into rows_json, row_count;
92
-
93
- return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
94
- end;
95
- $$;
96
-
97
- -- Finds the leading keyword of a statement's *outermost* clause, skipping
98
- -- past any leading CTE chain (`with x as (...), y as (...) <verb> ...`) by
99
- -- tracking paren depth rather than a single non-greedy regex match, which
100
- -- breaks on multiple CTEs or parens nested inside a CTE body. Returns one of
101
- -- 'select', 'insert', 'update', 'delete', or 'other' (lowercased).
102
- create or replace function public.cfni_top_level_verb(statement text)
103
- returns text
104
- language plpgsql
105
- immutable
106
- set search_path = ''
107
- as $$
108
- declare
109
- stripped text;
110
- depth int := 0;
111
- i int := 1;
112
- len int;
113
- ch text;
114
- begin
115
- stripped := regexp_replace(statement, '^(\s*--[^\n]*\n|\s*/\*.*?\*/)*\s*', '', 'sn');
116
-
117
- if stripped !~* '^with\y' then
118
- return lower((regexp_match(stripped, '^([A-Za-z]+)'))[1]);
119
- end if;
120
-
121
- len := length(stripped);
122
- while i <= len loop
123
- ch := substr(stripped, i, 1);
124
- if ch = '(' then
125
- depth := depth + 1;
126
- elsif ch = ')' then
127
- depth := depth - 1;
128
- elsif depth = 0 and ch = ',' then
129
- -- Between CTE definitions at depth 0; keep scanning for the
130
- -- statement that follows the whole `with ... ,` chain.
131
- null;
132
- elsif depth = 0 and substr(stripped, i) ~* '^(select|insert|update|delete)\y' then
133
- return lower((regexp_match(substr(stripped, i), '^([A-Za-z]+)'))[1]);
134
- end if;
135
- i := i + 1;
136
- end loop;
137
-
138
- return 'other';
139
- end;
140
- $$;
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
-
178
- revoke all on function public.cfni_exec(text) from public;
179
- grant execute on function public.cfni_exec(text) to authenticated;
180
- -- Grant to anon only if your app calls withPublicDb:
181
- grant execute on function public.cfni_exec(text) to anon;
182
- grant execute on function public.cfni_exec(text) to service_role;
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
-
189
- revoke all on function public.cfni_top_level_verb(text) from public;
190
- grant execute on function public.cfni_top_level_verb(text) to authenticated;
191
- grant execute on function public.cfni_top_level_verb(text) to anon;
192
- grant execute on function public.cfni_top_level_verb(text) to service_role;
@@ -1,193 +0,0 @@
1
- -- pgTAP tests for cfni_exec.sql. Run with `supabase test db` (or
2
- -- `pg_prove`) from a project that has installed both this file and
3
- -- `cfni_exec.sql`. Mirrors the scenarios in
4
- -- `src/db/cfni_exec.integration.test.ts` on the TypeScript side — this file
5
- -- checks the SQL function itself and its RLS interaction directly in
6
- -- Postgres, without going through the transport/JS parsing layer.
7
- begin;
8
- select plan(26);
9
-
10
- do $$
11
- begin
12
- if not exists (select 1 from pg_roles where rolname = 'anon') then
13
- create role anon;
14
- end if;
15
- if not exists (select 1 from pg_roles where rolname = 'authenticated') then
16
- create role authenticated;
17
- end if;
18
- end
19
- $$;
20
-
21
- create table cfni_test_a (id int primary key, name text);
22
- create table cfni_test_b (id int primary key, name text);
23
- insert into cfni_test_a values (1, 'a1');
24
- insert into cfni_test_b values (2, 'b2');
25
-
26
- select is(pg_typeof(cfni_exec('select 1'))::text, 'jsonb', 'cfni_exec returns jsonb');
27
-
28
- select is(
29
- cfni_exec('select id, name from cfni_test_a where id = 1'),
30
- '{"rows": ["(1,a1)"], "rowCount": 1}'::jsonb,
31
- 'plain select returns rows and rowCount'
32
- );
33
-
34
- select is(
35
- cfni_exec('select a.*, b.* from cfni_test_a a, cfni_test_b b'),
36
- '{"rows": ["(1,a1,2,b2)"], "rowCount": 1}'::jsonb,
37
- 'duplicate column names from a join are preserved positionally'
38
- );
39
-
40
- select is(
41
- cfni_exec('select array[1,2,3] as arr, true as b, 1.10::numeric as n, null as z'),
42
- '{"rows": ["(\"{1,2,3}\",t,1.10,)"], "rowCount": 1}'::jsonb,
43
- 'array/boolean/numeric/null values use pg text form, not JSON re-encoding'
44
- );
45
-
46
- select is(
47
- cfni_exec('insert into cfni_test_a (id, name) values (10, ''x'')'),
48
- '{"rows": [], "rowCount": 1}'::jsonb,
49
- 'insert without returning reports rowCount with no rows'
50
- );
51
-
52
- select is(
53
- cfni_exec('insert into cfni_test_a (id, name) values (11, ''y'') returning id, name'),
54
- '{"rows": ["(11,y)"], "rowCount": 1}'::jsonb,
55
- 'insert with returning reports rows'
56
- );
57
-
58
- select is(
59
- cfni_exec('update cfni_test_a set name = ''z'' where id = 1 returning id, name'),
60
- '{"rows": ["(1,z)"], "rowCount": 1}'::jsonb,
61
- 'update with returning reports rows'
62
- );
63
-
64
- select is(
65
- cfni_exec('update cfni_test_a set name = ''z2'' where id = 1'),
66
- '{"rows": [], "rowCount": 1}'::jsonb,
67
- 'update without returning reports rowCount with no rows'
68
- );
69
-
70
- select is(
71
- cfni_exec('delete from cfni_test_a where id = 10 returning id'),
72
- '{"rows": ["(10)"], "rowCount": 1}'::jsonb,
73
- 'delete with returning reports rows'
74
- );
75
-
76
- select is(
77
- cfni_exec('delete from cfni_test_a where id = 11'),
78
- '{"rows": [], "rowCount": 1}'::jsonb,
79
- 'delete without returning reports rowCount with no rows'
80
- );
81
-
82
- alter table cfni_test_a add constraint cfni_test_a_id_uq unique (id);
83
- select is(
84
- cfni_exec('insert into cfni_test_a (id, name) values (1, ''dup'') on conflict (id) do update set name = excluded.name returning id, name'),
85
- '{"rows": ["(1,dup)"], "rowCount": 1}'::jsonb,
86
- 'on conflict do update via excluded works'
87
- );
88
-
89
- select is(
90
- cfni_exec('with x as (select 1 as v) select v from x'),
91
- '{"rows": ["(1)"], "rowCount": 1}'::jsonb,
92
- 'plain with-select CTE works'
93
- );
94
-
95
- select is(
96
- cfni_exec('with src as (select 20 as id, ''q'' as name) insert into cfni_test_a (id, name) select id, name from src returning id, name'),
97
- '{"rows": ["(20,q)"], "rowCount": 1}'::jsonb,
98
- 'writable CTE (insert ... select ... returning) at the top level works'
99
- );
100
-
101
- select is(
102
- cfni_exec('with u as (update cfni_test_a set name = ''zz'' where id = 1 returning id) select id from u'),
103
- '{"rows": ["(1)"], "rowCount": 1}'::jsonb,
104
- 'writable CTE wrapped in an outer select works'
105
- );
106
-
107
- select is(
108
- cfni_exec('select ''the returning hero'' as s'),
109
- '{"rows": ["(\"the returning hero\")"], "rowCount": 1}'::jsonb,
110
- 'a select whose literal text contains the word "returning" is not misclassified as DML'
111
- );
112
-
113
- select is(
114
- cfni_exec('select 1 where false'),
115
- '{"rows": [], "rowCount": 0}'::jsonb,
116
- 'no matching rows returns an empty result set'
117
- );
118
-
119
- select is(pg_proc.prosecdef, false, 'cfni_exec is security invoker, not security definer')
120
- from pg_proc where proname = 'cfni_exec';
121
-
122
- -- Re-running the install file must not error (create-or-replace + a
123
- -- return-type-changing drop guard) — exercised by re-declaring the function
124
- -- with an identical body here, standing in for `\i cfni_exec.sql` a second
125
- -- time, since re-sourcing the actual file mid-transaction isn't expressible
126
- -- from within a single pgTAP script.
127
- select isnt(cfni_exec('select 1'), null, 'cfni_exec remains callable after a hypothetical reinstall');
128
-
129
- alter table cfni_test_a enable row level security;
130
- create policy cfni_test_a_select on cfni_test_a for select to authenticated using (true);
131
- grant select on cfni_test_a to anon, authenticated;
132
-
133
- set role anon;
134
- select is(
135
- cfni_exec('select * from cfni_test_a'),
136
- '{"rows": [], "rowCount": 0}'::jsonb,
137
- 'anon sees no rows with no matching RLS policy'
138
- );
139
- reset role;
140
-
141
- set role authenticated;
142
- select is(
143
- (cfni_exec('select * from cfni_test_a') ->> 'rowCount')::int,
144
- (select count(*)::int from cfni_test_a),
145
- 'authenticated sees all rows under its permissive select policy'
146
- );
147
- reset role;
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
-
192
- select * from finish();
193
- rollback;