@designfever/web-review-kit 0.1.0
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 +241 -0
- package/dist/chunk-U5K2YGGL.js +3486 -0
- package/dist/chunk-U5K2YGGL.js.map +1 -0
- package/dist/index.cjs +3524 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +26 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/react-shell.cjs +8841 -0
- package/dist/react-shell.cjs.map +1 -0
- package/dist/react-shell.d.cts +156 -0
- package/dist/react-shell.d.ts +156 -0
- package/dist/react-shell.js +5844 -0
- package/dist/react-shell.js.map +1 -0
- package/dist/types-D_mNjOHx.d.cts +183 -0
- package/dist/types-D_mNjOHx.d.ts +183 -0
- package/docs/README.md +37 -0
- package/docs/adapter-handoff.md +146 -0
- package/docs/concept.md +102 -0
- package/docs/df-sheet-adapter.md +336 -0
- package/docs/df-sheet-next.md +222 -0
- package/docs/initial-plan.md +226 -0
- package/docs/installation.md +222 -0
- package/docs/package-split-checkpoint.md +79 -0
- package/docs/presence-handoff.md +138 -0
- package/docs/review-feedback-2026-06-20.md +267 -0
- package/docs/smoke-baseline-2026-06-20.md +41 -0
- package/docs/stabilize-ui-work-guide.md +243 -0
- package/docs/supabase-presence.md +198 -0
- package/docs/supabase-review-items.md +365 -0
- package/docs/supabase.md +205 -0
- package/package.json +61 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Supabase presence adapter
|
|
2
|
+
|
|
3
|
+
## 목적
|
|
4
|
+
|
|
5
|
+
`df-web-review-kit`의 `ReviewPresenceAdapter` contract를 Supabase Realtime Presence로 구현하고 운영하기 위한 문서다.
|
|
6
|
+
|
|
7
|
+
Lexus pilot Supabase project:
|
|
8
|
+
|
|
9
|
+
```txt
|
|
10
|
+
name: bb-qa
|
|
11
|
+
url: https://vhqnvfkamnpgyqclohso.supabase.co
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
공식 문서 기준:
|
|
15
|
+
|
|
16
|
+
- Presence는 접속자/활성 문서 같은 느리게 변하는 상태 공유에 맞다: https://supabase.com/docs/guides/realtime/presence
|
|
17
|
+
- Realtime은 Broadcast, Presence, Postgres Changes를 제공한다: https://supabase.com/docs/guides/realtime
|
|
18
|
+
- DB 변경 fan-out은 Broadcast가 scalability/security 측면에서 권장되고, Postgres Changes는 단순하지만 scale이 약하다: https://supabase.com/docs/guides/realtime/subscribing-to-database-changes
|
|
19
|
+
- private Broadcast/Presence는 `realtime.messages` RLS policy와 `private: true` channel 설정이 필요하다: https://supabase.com/docs/guides/realtime/authorization
|
|
20
|
+
|
|
21
|
+
## 현재 결론
|
|
22
|
+
|
|
23
|
+
이번 presence 요구사항은 Supabase Presence가 맞다.
|
|
24
|
+
|
|
25
|
+
목표는 "QA item이 바뀌면 실시간으로 동기화"가 아니라 "현재 누가 어떤 page/viewport/source를 보고 있는지"다. 따라서 Postgres Changes나 Broadcast보다 Presence가 먼저다.
|
|
26
|
+
|
|
27
|
+
현재 shell UI는 두 단계로 보여준다.
|
|
28
|
+
|
|
29
|
+
- 우측 QA panel: 현재 page에 있는 user id만 표시
|
|
30
|
+
- sitemap: page별 user id 표시
|
|
31
|
+
|
|
32
|
+
## Channel 전략
|
|
33
|
+
|
|
34
|
+
권장 channel topic:
|
|
35
|
+
|
|
36
|
+
```txt
|
|
37
|
+
review-presence-<projectId>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
page별 channel이 아니라 project 단위 channel을 쓴다. 그래야 `/review`를 열어 둔 사람이 서로 다른 page를 보고 있어도 sitemap에서 "누가 어느 페이지에 있는지"를 볼 수 있다.
|
|
41
|
+
|
|
42
|
+
Payload의 `target`/`routeKey`로 page를 구분한다.
|
|
43
|
+
|
|
44
|
+
topic은 Supabase Realtime 호환성을 위해 `:` 같은 구분자를 쓰지 않고, adapter에서 `channelPrefix`와 `projectId`를 alphanumeric/`_`/`-` slug로 normalize한다.
|
|
45
|
+
|
|
46
|
+
## Adapter implementation
|
|
47
|
+
|
|
48
|
+
현재 구현 위치:
|
|
49
|
+
|
|
50
|
+
```txt
|
|
51
|
+
packages/df-web-review-kit/src/react-shell/supabase-presence.ts
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
현재 package adapter는 Supabase client를 직접 생성하지 않고 `client`를 주입받는다. Host page 쪽에서 `@supabase/supabase-js`의 `createClient()`를 호출해 넘긴다. 이렇게 하면 package 본체가 Supabase dependency에 강하게 묶이지 않는다.
|
|
55
|
+
|
|
56
|
+
현재 구현 특징:
|
|
57
|
+
|
|
58
|
+
- channel topic은 `review-presence-<projectId>`로 생성한다.
|
|
59
|
+
- `projectId`, `sessionId`, `userId`, `routeKey`, `target`, `source`, `viewport`, `mode`, `selectedItemId`, `selectedReviewNumber`를 track한다.
|
|
60
|
+
- StrictMode/HMR에서 같은 topic channel이 중복으로 남지 않도록 bridge/refCount를 둔다.
|
|
61
|
+
- `presenceState()` 결과는 `sessionId` 기준으로 dedupe한다.
|
|
62
|
+
- primary Supabase presence 연결이 실패하면 `createFallbackPresenceAdapter()`로 local BroadcastChannel presence를 쓸 수 있다.
|
|
63
|
+
|
|
64
|
+
## Public dev mode
|
|
65
|
+
|
|
66
|
+
초기 연결 검증은 public channel로도 가능하다.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const presence = createSupabasePresenceAdapter({
|
|
70
|
+
client: supabase,
|
|
71
|
+
private: false,
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
주의:
|
|
76
|
+
|
|
77
|
+
- public channel은 같은 Supabase project key를 가진 사용자가 topic을 알면 subscribe 가능하다.
|
|
78
|
+
- dev smoke test까지만 허용한다.
|
|
79
|
+
|
|
80
|
+
## Private production mode
|
|
81
|
+
|
|
82
|
+
production은 private channel을 권장한다.
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const presence = createSupabasePresenceAdapter({
|
|
86
|
+
client: supabase,
|
|
87
|
+
private: true,
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
client는 인증 세션을 가진 Supabase client여야 한다. private channel은 Realtime Authorization policy가 필요하다.
|
|
92
|
+
|
|
93
|
+
## RLS sketch
|
|
94
|
+
|
|
95
|
+
Supabase Realtime Authorization은 `realtime.messages`에 대한 RLS policy로 Broadcast/Presence 권한을 계산한다.
|
|
96
|
+
|
|
97
|
+
project membership table을 둔다고 가정한다.
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
create table if not exists public.review_project_members (
|
|
101
|
+
project_id text not null,
|
|
102
|
+
user_id uuid not null references auth.users(id) on delete cascade,
|
|
103
|
+
created_at timestamptz not null default now(),
|
|
104
|
+
primary key (project_id, user_id)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
alter table public.review_project_members enable row level security;
|
|
108
|
+
|
|
109
|
+
create policy review_project_members_read_own
|
|
110
|
+
on public.review_project_members
|
|
111
|
+
for select
|
|
112
|
+
to authenticated
|
|
113
|
+
using ((select auth.uid()) = user_id);
|
|
114
|
+
|
|
115
|
+
create index if not exists review_project_members_user_project_idx
|
|
116
|
+
on public.review_project_members (user_id, project_id);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Presence topic은 `review-presence-<projectId>` 형식이므로 RLS에서 topic을 project id로 해석한다.
|
|
120
|
+
|
|
121
|
+
```sql
|
|
122
|
+
create or replace function public.review_presence_project_id(topic text)
|
|
123
|
+
returns text
|
|
124
|
+
language sql
|
|
125
|
+
immutable
|
|
126
|
+
as $$
|
|
127
|
+
select nullif(regexp_replace(topic, '^review-presence-', ''), topic);
|
|
128
|
+
$$;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Presence read/write policy:
|
|
132
|
+
|
|
133
|
+
```sql
|
|
134
|
+
create policy "authenticated can listen to review presence"
|
|
135
|
+
on realtime.messages
|
|
136
|
+
for select
|
|
137
|
+
to authenticated
|
|
138
|
+
using (
|
|
139
|
+
realtime.messages.extension = 'presence'
|
|
140
|
+
and exists (
|
|
141
|
+
select 1
|
|
142
|
+
from public.review_project_members member
|
|
143
|
+
where member.user_id = (select auth.uid())
|
|
144
|
+
and member.project_id = public.review_presence_project_id((select realtime.topic()))
|
|
145
|
+
)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
create policy "authenticated can track review presence"
|
|
149
|
+
on realtime.messages
|
|
150
|
+
for insert
|
|
151
|
+
to authenticated
|
|
152
|
+
with check (
|
|
153
|
+
realtime.messages.extension = 'presence'
|
|
154
|
+
and exists (
|
|
155
|
+
select 1
|
|
156
|
+
from public.review_project_members member
|
|
157
|
+
where member.user_id = (select auth.uid())
|
|
158
|
+
and member.project_id = public.review_presence_project_id((select realtime.topic()))
|
|
159
|
+
)
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
RLS performance notes:
|
|
164
|
+
|
|
165
|
+
- `auth.uid()`는 `(select auth.uid())` 형태로 감싸서 per-row 호출을 줄인다.
|
|
166
|
+
- RLS 조건에서 조회하는 `(user_id, project_id)`는 composite index를 둔다.
|
|
167
|
+
- policy가 복잡해질수록 channel join latency가 늘 수 있다.
|
|
168
|
+
|
|
169
|
+
## Env
|
|
170
|
+
|
|
171
|
+
Lexus pilot에 붙일 때 후보 env:
|
|
172
|
+
|
|
173
|
+
```txt
|
|
174
|
+
VITE_REVIEW_SUPABASE_URL=https://vhqnvfkamnpgyqclohso.supabase.co
|
|
175
|
+
VITE_REVIEW_SUPABASE_ANON_KEY=
|
|
176
|
+
VITE_REVIEW_SUPABASE_PRESENCE_PRIVATE=false
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
브라우저에는 anon key만 둔다. service role key는 절대 넣지 않는다.
|
|
180
|
+
|
|
181
|
+
`VITE_REVIEW_SUPABASE_ANON_KEY`가 비어 있으면 shell은 local BroadcastChannel presence로 fallback한다.
|
|
182
|
+
|
|
183
|
+
## QA checklist
|
|
184
|
+
|
|
185
|
+
1. Settings에서 각 브라우저에 다른 `User ID` 입력
|
|
186
|
+
2. Browser A/B: 같은 target page로 `/review?target=/&w=540&h=1080`
|
|
187
|
+
3. A/B 양쪽 우측 QA panel에서 서로의 user id 확인
|
|
188
|
+
4. Browser B를 다른 target page로 이동
|
|
189
|
+
5. A의 우측 QA panel에서는 B가 사라지고, sitemap에서는 B가 이동한 page에 표시되는지 확인
|
|
190
|
+
6. B에서 viewport 변경 후 presence payload의 viewport가 바뀌는지 devtools로 확인
|
|
191
|
+
7. B에서 다른 QA item 선택 후 `selectedReviewNumber`가 payload에 들어가는지 devtools로 확인
|
|
192
|
+
8. B tab close 후 A에서 B가 사라지는지 확인
|
|
193
|
+
|
|
194
|
+
## Open decisions
|
|
195
|
+
|
|
196
|
+
- public dev mode를 package에 남길지.
|
|
197
|
+
- private presence를 실제 production에서 쓸 때 auth/member policy를 어디까지 package 문서화할지.
|
|
198
|
+
- sitemap presence를 별도 dashboard로 키울지.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# Supabase review item adapter
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`df-web-review-kit`의 remote QA 저장소를 Supabase table로 검증하기 위한 문서다.
|
|
6
|
+
|
|
7
|
+
Lexus pilot:
|
|
8
|
+
|
|
9
|
+
```txt
|
|
10
|
+
project: bb-qa
|
|
11
|
+
url: https://vhqnvfkamnpgyqclohso.supabase.co
|
|
12
|
+
table: review_items
|
|
13
|
+
source: supabase
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Environment
|
|
17
|
+
|
|
18
|
+
```env
|
|
19
|
+
VITE_REVIEW_SUPABASE_URL=https://vhqnvfkamnpgyqclohso.supabase.co
|
|
20
|
+
VITE_REVIEW_SUPABASE_ANON_KEY=
|
|
21
|
+
VITE_REVIEW_SUPABASE_TABLE=review_items
|
|
22
|
+
VITE_REVIEW_SUPABASE_PRESENCE_PRIVATE=false
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`anon` key만 browser env에 넣는다. `service_role` key는 browser env에 넣지 않는다.
|
|
26
|
+
|
|
27
|
+
## Schema
|
|
28
|
+
|
|
29
|
+
Supabase SQL editor에서 실행한다.
|
|
30
|
+
|
|
31
|
+
```sql
|
|
32
|
+
create table if not exists public.review_items (
|
|
33
|
+
id text primary key,
|
|
34
|
+
project_id text not null,
|
|
35
|
+
route_key text not null,
|
|
36
|
+
source text not null default 'supabase',
|
|
37
|
+
review_number integer,
|
|
38
|
+
status text not null default 'todo',
|
|
39
|
+
item jsonb not null,
|
|
40
|
+
created_at timestamptz not null default now(),
|
|
41
|
+
updated_at timestamptz not null default now()
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
create unique index if not exists review_items_project_review_number_idx
|
|
45
|
+
on public.review_items (project_id, source, review_number)
|
|
46
|
+
where review_number is not null;
|
|
47
|
+
|
|
48
|
+
create index if not exists review_items_project_route_updated_idx
|
|
49
|
+
on public.review_items (project_id, source, route_key, updated_at desc);
|
|
50
|
+
|
|
51
|
+
create index if not exists review_items_project_status_idx
|
|
52
|
+
on public.review_items (project_id, source, status);
|
|
53
|
+
|
|
54
|
+
create table if not exists public.review_project_counters (
|
|
55
|
+
project_id text not null,
|
|
56
|
+
source text not null default 'supabase',
|
|
57
|
+
next_review_number integer not null default 1,
|
|
58
|
+
updated_at timestamptz not null default now(),
|
|
59
|
+
primary key (project_id, source),
|
|
60
|
+
constraint review_project_counters_next_review_number_check
|
|
61
|
+
check (next_review_number > 0)
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
create or replace function public.create_review_item(
|
|
65
|
+
p_id text,
|
|
66
|
+
p_project_id text,
|
|
67
|
+
p_route_key text,
|
|
68
|
+
p_source text,
|
|
69
|
+
p_status text,
|
|
70
|
+
p_item jsonb
|
|
71
|
+
)
|
|
72
|
+
returns public.review_items
|
|
73
|
+
language plpgsql
|
|
74
|
+
security invoker
|
|
75
|
+
set search_path = public
|
|
76
|
+
as $$
|
|
77
|
+
declare
|
|
78
|
+
v_review_number integer;
|
|
79
|
+
v_now timestamptz := now();
|
|
80
|
+
v_row public.review_items;
|
|
81
|
+
begin
|
|
82
|
+
insert into public.review_project_counters (
|
|
83
|
+
project_id,
|
|
84
|
+
source,
|
|
85
|
+
next_review_number,
|
|
86
|
+
updated_at
|
|
87
|
+
)
|
|
88
|
+
select
|
|
89
|
+
p_project_id,
|
|
90
|
+
p_source,
|
|
91
|
+
coalesce(max(review_number), 0) + 2,
|
|
92
|
+
v_now
|
|
93
|
+
from public.review_items
|
|
94
|
+
where project_id = p_project_id
|
|
95
|
+
and source = p_source
|
|
96
|
+
on conflict (project_id, source) do update
|
|
97
|
+
set next_review_number = greatest(
|
|
98
|
+
public.review_project_counters.next_review_number + 1,
|
|
99
|
+
excluded.next_review_number
|
|
100
|
+
),
|
|
101
|
+
updated_at = excluded.updated_at
|
|
102
|
+
returning next_review_number - 1 into v_review_number;
|
|
103
|
+
|
|
104
|
+
insert into public.review_items (
|
|
105
|
+
id,
|
|
106
|
+
project_id,
|
|
107
|
+
route_key,
|
|
108
|
+
source,
|
|
109
|
+
review_number,
|
|
110
|
+
status,
|
|
111
|
+
item,
|
|
112
|
+
created_at,
|
|
113
|
+
updated_at
|
|
114
|
+
)
|
|
115
|
+
values (
|
|
116
|
+
p_id,
|
|
117
|
+
p_project_id,
|
|
118
|
+
p_route_key,
|
|
119
|
+
p_source,
|
|
120
|
+
v_review_number,
|
|
121
|
+
p_status,
|
|
122
|
+
p_item || jsonb_build_object(
|
|
123
|
+
'id', p_id,
|
|
124
|
+
'reviewNumber', v_review_number,
|
|
125
|
+
'projectId', p_project_id,
|
|
126
|
+
'routeKey', p_route_key,
|
|
127
|
+
'normalizedPath', coalesce(nullif(p_item->>'normalizedPath', ''), p_route_key),
|
|
128
|
+
'status', p_status,
|
|
129
|
+
'externalIssueId', p_id,
|
|
130
|
+
'submittedAt', coalesce(p_item->>'submittedAt', v_now::text),
|
|
131
|
+
'submitStatus', coalesce(p_item->>'submitStatus', 'submitted'),
|
|
132
|
+
'createdAt', v_now::text,
|
|
133
|
+
'updatedAt', v_now::text
|
|
134
|
+
),
|
|
135
|
+
v_now,
|
|
136
|
+
v_now
|
|
137
|
+
)
|
|
138
|
+
returning * into v_row;
|
|
139
|
+
|
|
140
|
+
return v_row;
|
|
141
|
+
end;
|
|
142
|
+
$$;
|
|
143
|
+
|
|
144
|
+
grant execute on function public.create_review_item(
|
|
145
|
+
text,
|
|
146
|
+
text,
|
|
147
|
+
text,
|
|
148
|
+
text,
|
|
149
|
+
text,
|
|
150
|
+
jsonb
|
|
151
|
+
) to anon;
|
|
152
|
+
|
|
153
|
+
grant select, insert, update, delete on public.review_items to anon;
|
|
154
|
+
grant select, insert, update on public.review_project_counters to anon;
|
|
155
|
+
|
|
156
|
+
alter table public.review_items enable row level security;
|
|
157
|
+
alter table public.review_project_counters enable row level security;
|
|
158
|
+
|
|
159
|
+
drop policy if exists review_items_lexus_read on public.review_items;
|
|
160
|
+
create policy review_items_lexus_read
|
|
161
|
+
on public.review_items
|
|
162
|
+
for select
|
|
163
|
+
to anon
|
|
164
|
+
using (project_id = 'lexus-official-v2026');
|
|
165
|
+
|
|
166
|
+
drop policy if exists review_items_lexus_insert on public.review_items;
|
|
167
|
+
create policy review_items_lexus_insert
|
|
168
|
+
on public.review_items
|
|
169
|
+
for insert
|
|
170
|
+
to anon
|
|
171
|
+
with check (project_id = 'lexus-official-v2026');
|
|
172
|
+
|
|
173
|
+
drop policy if exists review_items_lexus_update on public.review_items;
|
|
174
|
+
create policy review_items_lexus_update
|
|
175
|
+
on public.review_items
|
|
176
|
+
for update
|
|
177
|
+
to anon
|
|
178
|
+
using (project_id = 'lexus-official-v2026')
|
|
179
|
+
with check (project_id = 'lexus-official-v2026');
|
|
180
|
+
|
|
181
|
+
drop policy if exists review_items_lexus_delete on public.review_items;
|
|
182
|
+
create policy review_items_lexus_delete
|
|
183
|
+
on public.review_items
|
|
184
|
+
for delete
|
|
185
|
+
to anon
|
|
186
|
+
using (project_id = 'lexus-official-v2026');
|
|
187
|
+
|
|
188
|
+
drop policy if exists review_project_counters_lexus_read on public.review_project_counters;
|
|
189
|
+
create policy review_project_counters_lexus_read
|
|
190
|
+
on public.review_project_counters
|
|
191
|
+
for select
|
|
192
|
+
to anon
|
|
193
|
+
using (project_id = 'lexus-official-v2026');
|
|
194
|
+
|
|
195
|
+
drop policy if exists review_project_counters_lexus_insert on public.review_project_counters;
|
|
196
|
+
create policy review_project_counters_lexus_insert
|
|
197
|
+
on public.review_project_counters
|
|
198
|
+
for insert
|
|
199
|
+
to anon
|
|
200
|
+
with check (project_id = 'lexus-official-v2026');
|
|
201
|
+
|
|
202
|
+
drop policy if exists review_project_counters_lexus_update on public.review_project_counters;
|
|
203
|
+
create policy review_project_counters_lexus_update
|
|
204
|
+
on public.review_project_counters
|
|
205
|
+
for update
|
|
206
|
+
to anon
|
|
207
|
+
using (project_id = 'lexus-official-v2026')
|
|
208
|
+
with check (project_id = 'lexus-official-v2026');
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Migration from max+1 RPC
|
|
212
|
+
|
|
213
|
+
이미 `review_items`와 이전 `create_review_item` RPC를 만든 DB에서는 아래 블록만 실행한다.
|
|
214
|
+
|
|
215
|
+
```sql
|
|
216
|
+
create table if not exists public.review_project_counters (
|
|
217
|
+
project_id text not null,
|
|
218
|
+
source text not null default 'supabase',
|
|
219
|
+
next_review_number integer not null default 1,
|
|
220
|
+
updated_at timestamptz not null default now(),
|
|
221
|
+
primary key (project_id, source),
|
|
222
|
+
constraint review_project_counters_next_review_number_check
|
|
223
|
+
check (next_review_number > 0)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
create or replace function public.create_review_item(
|
|
227
|
+
p_id text,
|
|
228
|
+
p_project_id text,
|
|
229
|
+
p_route_key text,
|
|
230
|
+
p_source text,
|
|
231
|
+
p_status text,
|
|
232
|
+
p_item jsonb
|
|
233
|
+
)
|
|
234
|
+
returns public.review_items
|
|
235
|
+
language plpgsql
|
|
236
|
+
security invoker
|
|
237
|
+
set search_path = public
|
|
238
|
+
as $$
|
|
239
|
+
declare
|
|
240
|
+
v_review_number integer;
|
|
241
|
+
v_now timestamptz := now();
|
|
242
|
+
v_row public.review_items;
|
|
243
|
+
begin
|
|
244
|
+
insert into public.review_project_counters (
|
|
245
|
+
project_id,
|
|
246
|
+
source,
|
|
247
|
+
next_review_number,
|
|
248
|
+
updated_at
|
|
249
|
+
)
|
|
250
|
+
select
|
|
251
|
+
p_project_id,
|
|
252
|
+
p_source,
|
|
253
|
+
coalesce(max(review_number), 0) + 2,
|
|
254
|
+
v_now
|
|
255
|
+
from public.review_items
|
|
256
|
+
where project_id = p_project_id
|
|
257
|
+
and source = p_source
|
|
258
|
+
on conflict (project_id, source) do update
|
|
259
|
+
set next_review_number = greatest(
|
|
260
|
+
public.review_project_counters.next_review_number + 1,
|
|
261
|
+
excluded.next_review_number
|
|
262
|
+
),
|
|
263
|
+
updated_at = excluded.updated_at
|
|
264
|
+
returning next_review_number - 1 into v_review_number;
|
|
265
|
+
|
|
266
|
+
insert into public.review_items (
|
|
267
|
+
id,
|
|
268
|
+
project_id,
|
|
269
|
+
route_key,
|
|
270
|
+
source,
|
|
271
|
+
review_number,
|
|
272
|
+
status,
|
|
273
|
+
item,
|
|
274
|
+
created_at,
|
|
275
|
+
updated_at
|
|
276
|
+
)
|
|
277
|
+
values (
|
|
278
|
+
p_id,
|
|
279
|
+
p_project_id,
|
|
280
|
+
p_route_key,
|
|
281
|
+
p_source,
|
|
282
|
+
v_review_number,
|
|
283
|
+
p_status,
|
|
284
|
+
p_item || jsonb_build_object(
|
|
285
|
+
'id', p_id,
|
|
286
|
+
'reviewNumber', v_review_number,
|
|
287
|
+
'projectId', p_project_id,
|
|
288
|
+
'routeKey', p_route_key,
|
|
289
|
+
'normalizedPath', coalesce(nullif(p_item->>'normalizedPath', ''), p_route_key),
|
|
290
|
+
'status', p_status,
|
|
291
|
+
'externalIssueId', p_id,
|
|
292
|
+
'submittedAt', coalesce(p_item->>'submittedAt', v_now::text),
|
|
293
|
+
'submitStatus', coalesce(p_item->>'submitStatus', 'submitted'),
|
|
294
|
+
'createdAt', v_now::text,
|
|
295
|
+
'updatedAt', v_now::text
|
|
296
|
+
),
|
|
297
|
+
v_now,
|
|
298
|
+
v_now
|
|
299
|
+
)
|
|
300
|
+
returning * into v_row;
|
|
301
|
+
|
|
302
|
+
return v_row;
|
|
303
|
+
end;
|
|
304
|
+
$$;
|
|
305
|
+
|
|
306
|
+
grant execute on function public.create_review_item(
|
|
307
|
+
text,
|
|
308
|
+
text,
|
|
309
|
+
text,
|
|
310
|
+
text,
|
|
311
|
+
text,
|
|
312
|
+
jsonb
|
|
313
|
+
) to anon;
|
|
314
|
+
|
|
315
|
+
grant select, insert, update on public.review_project_counters to anon;
|
|
316
|
+
|
|
317
|
+
alter table public.review_project_counters enable row level security;
|
|
318
|
+
|
|
319
|
+
drop policy if exists review_project_counters_lexus_read on public.review_project_counters;
|
|
320
|
+
create policy review_project_counters_lexus_read
|
|
321
|
+
on public.review_project_counters
|
|
322
|
+
for select
|
|
323
|
+
to anon
|
|
324
|
+
using (project_id = 'lexus-official-v2026');
|
|
325
|
+
|
|
326
|
+
drop policy if exists review_project_counters_lexus_insert on public.review_project_counters;
|
|
327
|
+
create policy review_project_counters_lexus_insert
|
|
328
|
+
on public.review_project_counters
|
|
329
|
+
for insert
|
|
330
|
+
to anon
|
|
331
|
+
with check (project_id = 'lexus-official-v2026');
|
|
332
|
+
|
|
333
|
+
drop policy if exists review_project_counters_lexus_update on public.review_project_counters;
|
|
334
|
+
create policy review_project_counters_lexus_update
|
|
335
|
+
on public.review_project_counters
|
|
336
|
+
for update
|
|
337
|
+
to anon
|
|
338
|
+
using (project_id = 'lexus-official-v2026')
|
|
339
|
+
with check (project_id = 'lexus-official-v2026');
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Current adapter behavior
|
|
343
|
+
|
|
344
|
+
- `list`: `project_id`, `source`, optional `route_key`, optional `status`로 조회한다.
|
|
345
|
+
- `create`: local draft id/number를 버리고 새 `id`를 만든 뒤 `create_review_item` RPC로 canonical `review_number`를 발급하고 insert한다.
|
|
346
|
+
- `update`: status 포함 item patch를 JSONB와 query 컬럼에 반영한다.
|
|
347
|
+
- `remove`: row를 삭제한다.
|
|
348
|
+
|
|
349
|
+
## Security note
|
|
350
|
+
|
|
351
|
+
위 RLS는 dev 검증용이다. 누구든 anon key와 project id를 알면 QA row를 만들고 수정할 수 있다.
|
|
352
|
+
|
|
353
|
+
package 배포 전에는 다음 중 하나로 좁히는 것을 권장한다.
|
|
354
|
+
|
|
355
|
+
- authenticated user + project member table 기반 RLS
|
|
356
|
+
- Supabase Edge Function 또는 project backend proxy
|
|
357
|
+
- private deployment 내부에서만 노출되는 service endpoint
|
|
358
|
+
|
|
359
|
+
## Numbering note
|
|
360
|
+
|
|
361
|
+
local item의 `#id`는 개인 draft 번호라 여러 사용자가 동시에 작업하면 겹칠 수 있다.
|
|
362
|
+
|
|
363
|
+
remote source에 등록될 때는 Supabase adapter가 local 번호를 버리고 새 canonical `review_number`를 받는다. 기본 create 경로는 `create_review_item` RPC를 사용한다. RPC는 `review_project_counters` row를 `insert ... on conflict ... do update returning`으로 증가시켜 같은 project/source 번호 발급만 짧게 직렬화한다. 삭제된 번호도 재사용하지 않는다.
|
|
364
|
+
|
|
365
|
+
`unsafeClientReviewNumberFallback: true`를 adapter option에 넣으면 예전 client-side `max(review_number) + 1` fallback을 사용할 수 있지만, 동시 등록에서 충돌 가능성이 있으므로 dev 임시 용도 외에는 쓰지 않는다.
|