cloudflare-next-intl 0.8.4 → 0.8.6
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 +217 -12
- package/bin/db_codegen.mjs +58 -8
- package/bin/db_install_exec.mjs +16 -0
- package/bin/install_exec_step.mjs +39 -0
- package/dist/src/db/codegen_paths.d.ts +12 -0
- package/dist/src/db/codegen_paths.js +33 -2
- package/dist/src/db/context.js +19 -1
- package/dist/src/db/encode_param.d.ts +10 -0
- package/dist/src/db/encode_param.js +60 -0
- package/dist/src/db/helpers.d.ts +2 -2
- package/dist/src/db/helpers.js +2 -2
- package/dist/src/db/index.d.ts +9 -0
- package/dist/src/db/index.js +8 -0
- package/dist/src/db/inline_params.d.ts +18 -0
- package/dist/src/db/inline_params.js +118 -0
- package/dist/src/db/install_exec.d.ts +24 -0
- package/dist/src/db/install_exec.js +42 -0
- package/dist/src/db/parse_composite.d.ts +17 -0
- package/dist/src/db/parse_composite.js +60 -0
- package/dist/src/db/resolve_raw_sql.d.ts +24 -0
- package/dist/src/db/resolve_raw_sql.js +55 -0
- package/dist/src/db/schema.d.ts +9 -0
- package/dist/src/db/schema.js +9 -0
- package/dist/src/db/supabase_rest.d.ts +178 -0
- package/dist/src/db/supabase_rest.js +262 -0
- package/dist/src/db/supabase_transport.d.ts +8 -0
- package/dist/src/db/supabase_transport.js +41 -2
- package/dist/src/types/types.d.ts +13 -0
- package/llms.txt +4 -2
- package/package.json +278 -273
- package/supabase/cfni_exec.sql +131 -16
- package/supabase/tests/cfni_exec.sql +150 -0
package/supabase/cfni_exec.sql
CHANGED
|
@@ -1,34 +1,149 @@
|
|
|
1
1
|
-- Executes a statement generated by cloudflare-next-intl's `db` module and
|
|
2
|
-
-- returns its rows as
|
|
3
|
-
--
|
|
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`.
|
|
4
21
|
--
|
|
5
22
|
-- SECURITY INVOKER is load-bearing: the statement runs with the privileges of
|
|
6
23
|
-- the caller (anon or authenticated, per the request's JWT), so row-level
|
|
7
24
|
-- security still applies exactly as it does over the REST API. Do not change
|
|
8
25
|
-- this to SECURITY DEFINER, and do not add a role parameter — either would
|
|
9
26
|
-- turn this into a privilege-escalation primitive.
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
12
35
|
language plpgsql
|
|
13
36
|
security invoker
|
|
14
37
|
as $$
|
|
15
38
|
declare
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
top_level_verb text;
|
|
40
|
+
has_returning boolean;
|
|
41
|
+
has_inner_writable_cte boolean;
|
|
42
|
+
rows_json jsonb := '[]'::jsonb;
|
|
43
|
+
row_count bigint := 0;
|
|
44
|
+
row_value record;
|
|
45
|
+
row_texts text[] := '{}';
|
|
46
|
+
begin
|
|
47
|
+
top_level_verb := public.cfni_top_level_verb(statement);
|
|
48
|
+
has_returning := statement ~* '\yreturning\y';
|
|
49
|
+
|
|
50
|
+
if top_level_verb in ('insert', 'update', 'delete') and not has_returning then
|
|
51
|
+
execute statement;
|
|
52
|
+
get diagnostics row_count = row_count;
|
|
53
|
+
return jsonb_build_object('rows', '[]'::jsonb, 'rowCount', row_count);
|
|
54
|
+
end if;
|
|
55
|
+
|
|
56
|
+
if top_level_verb in ('insert', 'update', 'delete') then
|
|
57
|
+
-- INSERT/UPDATE/DELETE with RETURNING cannot be wrapped as
|
|
58
|
+
-- `from (<stmt>) r` — Postgres rejects DML directly inside FROM —
|
|
59
|
+
-- so run it as a CTE instead and select its rows out of that.
|
|
60
|
+
execute format(
|
|
61
|
+
'with cfni_dml_result as (%s) select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from cfni_dml_result r',
|
|
62
|
+
statement
|
|
63
|
+
) into rows_json, row_count;
|
|
64
|
+
return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
|
|
65
|
+
end if;
|
|
66
|
+
|
|
67
|
+
-- Top-level statement is a SELECT (plain, or `with ... select ...`).
|
|
68
|
+
has_inner_writable_cte := statement ~* '\y(with|,)\s+[A-Za-z_][A-Za-z0-9_]*\s+as\s*\(\s*(insert|update|delete)\y';
|
|
69
|
+
if has_inner_writable_cte then
|
|
70
|
+
-- Postgres forbids nesting a WITH that contains a data-modifying
|
|
71
|
+
-- statement inside another query (`from (with u as (update ...
|
|
72
|
+
-- returning ...) select ... from u) r` is rejected even though the
|
|
73
|
+
-- statement is legal standalone) — so this shape must run exactly
|
|
74
|
+
-- as given, not wrapped in a further subquery. Row values are
|
|
75
|
+
-- collected by iterating the statement's own result set instead,
|
|
76
|
+
-- casting each row to its composite-literal text form directly.
|
|
77
|
+
for row_value in execute statement loop
|
|
78
|
+
row_texts := row_texts || (row_value::text);
|
|
79
|
+
row_count := row_count + 1;
|
|
80
|
+
end loop;
|
|
81
|
+
return jsonb_build_object('rows', to_jsonb(row_texts), 'rowCount', row_count);
|
|
82
|
+
end if;
|
|
83
|
+
|
|
84
|
+
-- Plain SELECT with no inner writable CTE: wrap as a subquery and emit
|
|
85
|
+
-- each row's composite-literal text form; the transport parses it back
|
|
86
|
+
-- into a positional array of column values.
|
|
87
|
+
execute format(
|
|
88
|
+
'select coalesce(jsonb_agg(r::text), ''[]''::jsonb), count(*) from (%s) r',
|
|
89
|
+
statement
|
|
90
|
+
) into rows_json, row_count;
|
|
91
|
+
|
|
92
|
+
return jsonb_build_object('rows', rows_json, 'rowCount', row_count);
|
|
93
|
+
end;
|
|
94
|
+
$$;
|
|
95
|
+
|
|
96
|
+
-- Finds the leading keyword of a statement's *outermost* clause, skipping
|
|
97
|
+
-- past any leading CTE chain (`with x as (...), y as (...) <verb> ...`) by
|
|
98
|
+
-- tracking paren depth rather than a single non-greedy regex match, which
|
|
99
|
+
-- breaks on multiple CTEs or parens nested inside a CTE body. Returns one of
|
|
100
|
+
-- 'select', 'insert', 'update', 'delete', or 'other' (lowercased).
|
|
101
|
+
create or replace function public.cfni_top_level_verb(statement text)
|
|
102
|
+
returns text
|
|
103
|
+
language plpgsql
|
|
104
|
+
immutable
|
|
105
|
+
as $$
|
|
106
|
+
declare
|
|
107
|
+
stripped text;
|
|
108
|
+
depth int := 0;
|
|
109
|
+
i int := 1;
|
|
110
|
+
len int;
|
|
111
|
+
ch text;
|
|
18
112
|
begin
|
|
19
|
-
|
|
20
|
-
into args
|
|
21
|
-
from jsonb_array_elements(params) with ordinality as t(value, ordinality);
|
|
113
|
+
stripped := regexp_replace(statement, '^(\s*--[^\n]*\n|\s*/\*.*?\*/)*\s*', '', 'sn');
|
|
22
114
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
115
|
+
if stripped !~* '^with\y' then
|
|
116
|
+
return lower((regexp_match(stripped, '^([A-Za-z]+)'))[1]);
|
|
117
|
+
end if;
|
|
26
118
|
|
|
27
|
-
|
|
119
|
+
len := length(stripped);
|
|
120
|
+
while i <= len loop
|
|
121
|
+
ch := substr(stripped, i, 1);
|
|
122
|
+
if ch = '(' then
|
|
123
|
+
depth := depth + 1;
|
|
124
|
+
elsif ch = ')' then
|
|
125
|
+
depth := depth - 1;
|
|
126
|
+
elsif depth = 0 and ch = ',' then
|
|
127
|
+
-- Between CTE definitions at depth 0; keep scanning for the
|
|
128
|
+
-- statement that follows the whole `with ... ,` chain.
|
|
129
|
+
null;
|
|
130
|
+
elsif depth = 0 and substr(stripped, i) ~* '^(select|insert|update|delete)\y' then
|
|
131
|
+
return lower((regexp_match(substr(stripped, i), '^([A-Za-z]+)'))[1]);
|
|
132
|
+
end if;
|
|
133
|
+
i := i + 1;
|
|
134
|
+
end loop;
|
|
135
|
+
|
|
136
|
+
return 'other';
|
|
28
137
|
end;
|
|
29
138
|
$$;
|
|
30
139
|
|
|
31
|
-
revoke all on function public.cfni_exec(text
|
|
32
|
-
grant execute on function public.cfni_exec(text
|
|
140
|
+
revoke all on function public.cfni_exec(text) from public;
|
|
141
|
+
grant execute on function public.cfni_exec(text) to authenticated;
|
|
33
142
|
-- Grant to anon only if your app calls withPublicDb:
|
|
34
|
-
grant execute on function public.cfni_exec(text
|
|
143
|
+
grant execute on function public.cfni_exec(text) to anon;
|
|
144
|
+
grant execute on function public.cfni_exec(text) to service_role;
|
|
145
|
+
|
|
146
|
+
revoke all on function public.cfni_top_level_verb(text) from public;
|
|
147
|
+
grant execute on function public.cfni_top_level_verb(text) to authenticated;
|
|
148
|
+
grant execute on function public.cfni_top_level_verb(text) to anon;
|
|
149
|
+
grant execute on function public.cfni_top_level_verb(text) to service_role;
|
|
@@ -0,0 +1,150 @@
|
|
|
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(20);
|
|
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
|
+
select * from finish();
|
|
150
|
+
rollback;
|