@fayz-ai/db 0.10.0 → 0.12.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/dist/index.cjs +39 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -2
- package/dist/index.js.map +1 -1
- package/dist/schema/spine.d.ts +553 -0
- package/dist/schema/spine.d.ts.map +1 -1
- package/migrations/025_created_by.sql +75 -0
- package/migrations/026_audit_trail.sql +73 -0
- package/migrations/027_domain_events.sql +266 -0
- package/migrations/028_tenant_scoped_token.sql +127 -0
- package/migrations/029_connections.sql +186 -0
- package/migrations/030_effect_idempotency.sql +159 -0
- package/migrations/031_sync_run_message.sql +39 -0
- package/migrations/032_connection_secrets.sql +227 -0
- package/migrations/033_sync_schedule.sql +651 -0
- package/migrations/034_custom_fields.sql +55 -0
- package/migrations/035_field_registry.sql +148 -0
- package/migrations/036_analytics_run_batch.sql +84 -0
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +81 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/package.json +7 -4
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 026_audit_trail.sql — audit_logs vira leitura, não só depósito.
|
|
3
|
+
--
|
|
4
|
+
-- A tabela existe desde o 001_core e hoje recebe linhas de um lugar só: os RPCs
|
|
5
|
+
-- do agente (`agent_forms_upsert_template` e irmãos). Tudo que uma PESSOA faz
|
|
6
|
+
-- pela tela — criar cliente, corrigir um telefone, excluir um serviço — não
|
|
7
|
+
-- deixava rastro nenhum. O resultado é que "quem mudou isso, e quando" não tinha
|
|
8
|
+
-- resposta em lugar nenhum do produto.
|
|
9
|
+
--
|
|
10
|
+
-- Esta migração NÃO põe gatilho de auditoria em tabela nenhuma. A escrita passa
|
|
11
|
+
-- a sair do único ponto por onde todo cadastro do app já passa — o store de CRUD
|
|
12
|
+
-- (packages/admin/src/stores/createCrudStore.ts), que hoje já emite os eventos
|
|
13
|
+
-- de domínio de created/updated/deleted. Um gatilho por tabela pegaria também as
|
|
14
|
+
-- escritas fora do app, mas custaria uma migração a cada cadastro novo, e
|
|
15
|
+
-- "auditoria funciona pra qualquer cadastro" viraria "funciona nos cadastros de
|
|
16
|
+
-- que alguém lembrou". No chokepoint, um cadastro novo já nasce auditado.
|
|
17
|
+
--
|
|
18
|
+
-- Aqui só fica o que é do banco: a permissão para gravar, quem gravou, e os
|
|
19
|
+
-- índices que fazem essa tabela ser lida sem varredura.
|
|
20
|
+
-- ============================================================================
|
|
21
|
+
|
|
22
|
+
-- ── Quem gravou ─────────────────────────────────────────────────────────────
|
|
23
|
+
-- Mesma escolha do 025: por gatilho, e não `DEFAULT auth.uid()` na coluna,
|
|
24
|
+
-- para o service_role (edge function, backfill) poder gravar um autor explícito
|
|
25
|
+
-- sem que "escrever NULL de propósito" e "não escrever nada" virem a mesma coisa.
|
|
26
|
+
CREATE OR REPLACE FUNCTION public.handle_audit_actor()
|
|
27
|
+
RETURNS trigger
|
|
28
|
+
LANGUAGE plpgsql
|
|
29
|
+
SECURITY DEFINER
|
|
30
|
+
SET search_path = public
|
|
31
|
+
AS $fn$
|
|
32
|
+
BEGIN
|
|
33
|
+
NEW.user_id := COALESCE(NEW.user_id, auth.uid());
|
|
34
|
+
RETURN NEW;
|
|
35
|
+
END;
|
|
36
|
+
$fn$;
|
|
37
|
+
|
|
38
|
+
REVOKE ALL ON FUNCTION public.handle_audit_actor() FROM public;
|
|
39
|
+
REVOKE ALL ON FUNCTION public.handle_audit_actor() FROM anon;
|
|
40
|
+
|
|
41
|
+
DROP TRIGGER IF EXISTS audit_logs_actor ON public.audit_logs;
|
|
42
|
+
CREATE TRIGGER audit_logs_actor BEFORE INSERT ON public.audit_logs
|
|
43
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_audit_actor();
|
|
44
|
+
|
|
45
|
+
-- ── Permissão de escrita ────────────────────────────────────────────────────
|
|
46
|
+
-- A política de INSERT estava em `WITH CHECK (true)`: qualquer usuário
|
|
47
|
+
-- autenticado podia gravar uma linha de auditoria em QUALQUER tenant do pool —
|
|
48
|
+
-- inclusive forjando ação em nome de outro. Numa tabela cujo propósito é ser
|
|
49
|
+
-- prova do que aconteceu, isso é o defeito mais caro possível.
|
|
50
|
+
--
|
|
51
|
+
-- `user_id` fica de fora da checagem de propósito: o gatilho acima o preenche
|
|
52
|
+
-- DEPOIS que a política roda, e o RPC do agente grava em nome do ator que o
|
|
53
|
+
-- broker injetou.
|
|
54
|
+
DROP POLICY IF EXISTS "audit_insert" ON public.audit_logs;
|
|
55
|
+
CREATE POLICY "audit_insert" ON public.audit_logs
|
|
56
|
+
FOR INSERT TO authenticated
|
|
57
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
58
|
+
|
|
59
|
+
-- Auditoria não se corrige e não se apaga pela aplicação. Sem política de
|
|
60
|
+
-- UPDATE/DELETE para `authenticated`, a RLS nega — que é o comportamento certo:
|
|
61
|
+
-- um registro de auditoria editável não prova nada. Correção e expurgo por
|
|
62
|
+
-- retenção são trabalho de service_role.
|
|
63
|
+
|
|
64
|
+
-- ── Leitura ─────────────────────────────────────────────────────────────────
|
|
65
|
+
-- A linha do tempo de uma ficha pergunta sempre a mesma coisa: "os eventos
|
|
66
|
+
-- DESTE registro, do mais novo para o mais velho". Sem este índice é varredura
|
|
67
|
+
-- na tabela inteira do tenant a cada abertura de ficha.
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
|
|
69
|
+
ON public.audit_logs (tenant_id, entity_type, entity_id, created_at DESC);
|
|
70
|
+
|
|
71
|
+
-- E o feed geral ("o que andou acontecendo"), que ordena só por data.
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_audit_logs_recent
|
|
73
|
+
ON public.audit_logs (tenant_id, created_at DESC);
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 027_domain_events.sql — the facts the browser never sees.
|
|
3
|
+
--
|
|
4
|
+
-- Domain events have exactly one emitter today: the CRUD store running in the
|
|
5
|
+
-- browser (packages/admin/src/stores/createCrudStore.ts). It only knows about
|
|
6
|
+
-- writes that pass through it. An edge function's upsert, a CSV import, a
|
|
7
|
+
-- webhook handler, a hand-run SQL fix — none of them produce a fact, and those
|
|
8
|
+
-- are precisely the writes an integration makes. So "notify me when a booking
|
|
9
|
+
-- is created" quietly stops being true the moment bookings start arriving from
|
|
10
|
+
-- outside the app, which is the whole point of having integrations.
|
|
11
|
+
--
|
|
12
|
+
-- This puts the emit seam where every write ends up no matter who made it:
|
|
13
|
+
-- `plg_emit_event` records one fact, `plg_attach_event_source` hangs a generic
|
|
14
|
+
-- trigger on a table. It lives in the core db package and not in
|
|
15
|
+
-- plugin-automations because `emitsFrom` is a manifest field ANY plugin may
|
|
16
|
+
-- declare — the ledger must not depend on an optional plugin being installed.
|
|
17
|
+
--
|
|
18
|
+
-- The ledger table is therefore (re)created here with the SAME shape as
|
|
19
|
+
-- plugins/plugin-automations/migrations/0002_event_log.sql: a pool that already
|
|
20
|
+
-- ran 0002 sees a no-op, a pool without that plugin gets the table.
|
|
21
|
+
--
|
|
22
|
+
-- Nothing is attached to a real table here. Declaring `emitsFrom` in a manifest
|
|
23
|
+
-- is how a plugin opts in, and that wiring is a later milestone.
|
|
24
|
+
-- ============================================================================
|
|
25
|
+
|
|
26
|
+
-- ── The ledger ──────────────────────────────────────────────────────────────
|
|
27
|
+
-- One row per FACT, not per message sent: the same paid order may drive an
|
|
28
|
+
-- e-mail, a webhook and the audit trail, and only a row tied to the fact can
|
|
29
|
+
-- answer "did the trigger fire at all?" without asking every consumer.
|
|
30
|
+
-- Byte-for-byte the shape of 0002 — divergence here would mean two different
|
|
31
|
+
-- tables answering to one name across the pool fleet.
|
|
32
|
+
CREATE TABLE IF NOT EXISTS public.plg_event_log (
|
|
33
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
34
|
+
tenant_id uuid NOT NULL,
|
|
35
|
+
event_key text NOT NULL,
|
|
36
|
+
status text NOT NULL DEFAULT 'recorded'
|
|
37
|
+
CHECK (status IN ('recorded', 'sent', 'skipped', 'failed')),
|
|
38
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
39
|
+
result jsonb,
|
|
40
|
+
error text,
|
|
41
|
+
actor_id uuid,
|
|
42
|
+
subject_type text,
|
|
43
|
+
subject_id text,
|
|
44
|
+
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS plg_event_log_lookup_idx
|
|
49
|
+
ON public.plg_event_log (tenant_id, event_key, occurred_at DESC);
|
|
50
|
+
|
|
51
|
+
CREATE INDEX IF NOT EXISTS plg_event_log_subject_idx
|
|
52
|
+
ON public.plg_event_log (tenant_id, subject_type, subject_id, occurred_at DESC);
|
|
53
|
+
|
|
54
|
+
ALTER TABLE public.plg_event_log ENABLE ROW LEVEL SECURITY;
|
|
55
|
+
|
|
56
|
+
DROP POLICY IF EXISTS plg_event_log_member_read ON public.plg_event_log;
|
|
57
|
+
CREATE POLICY plg_event_log_member_read ON public.plg_event_log
|
|
58
|
+
FOR SELECT TO authenticated
|
|
59
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
60
|
+
|
|
61
|
+
DROP POLICY IF EXISTS plg_event_log_member_insert ON public.plg_event_log;
|
|
62
|
+
CREATE POLICY plg_event_log_member_insert ON public.plg_event_log
|
|
63
|
+
FOR INSERT TO authenticated
|
|
64
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
65
|
+
|
|
66
|
+
-- No UPDATE/DELETE policy, same as 0002: a history the application can rewrite
|
|
67
|
+
-- is not a history. Marking 'sent'/'failed' is the dispatcher's job, server-side.
|
|
68
|
+
|
|
69
|
+
-- ── Dedupe key ──────────────────────────────────────────────────────────────
|
|
70
|
+
-- The SDK emitter mints its own uuid per event (packages/sdk/src/events.ts) so a
|
|
71
|
+
-- retried batch counts as duplicates instead of re-running automations. That id
|
|
72
|
+
-- belongs to the emitter's namespace, not to this table: `id` is the ledger's
|
|
73
|
+
-- own identity, is NOT NULL and is already filled on every legacy row, so it
|
|
74
|
+
-- cannot express "this caller had no dedupe key". A nullable column plus a
|
|
75
|
+
-- partial unique index can — emitters that dedupe get it, the trigger below and
|
|
76
|
+
-- anything else that has no natural id simply pass NULL.
|
|
77
|
+
ALTER TABLE public.plg_event_log ADD COLUMN IF NOT EXISTS event_id uuid;
|
|
78
|
+
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS plg_event_log_event_id_key
|
|
80
|
+
ON public.plg_event_log (event_id) WHERE event_id IS NOT NULL;
|
|
81
|
+
|
|
82
|
+
-- ── plg_emit_event ──────────────────────────────────────────────────────────
|
|
83
|
+
CREATE OR REPLACE FUNCTION public.plg_emit_event(
|
|
84
|
+
p_key text,
|
|
85
|
+
p_payload jsonb DEFAULT '{}'::jsonb,
|
|
86
|
+
p_subject_type text DEFAULT NULL,
|
|
87
|
+
p_subject_id text DEFAULT NULL,
|
|
88
|
+
p_tenant_id uuid DEFAULT NULL,
|
|
89
|
+
p_event_id uuid DEFAULT NULL
|
|
90
|
+
)
|
|
91
|
+
RETURNS uuid
|
|
92
|
+
LANGUAGE plpgsql
|
|
93
|
+
SECURITY DEFINER
|
|
94
|
+
SET search_path = public
|
|
95
|
+
AS $fn$
|
|
96
|
+
DECLARE
|
|
97
|
+
v_tenants uuid[];
|
|
98
|
+
v_tenant uuid := p_tenant_id;
|
|
99
|
+
v_id uuid;
|
|
100
|
+
BEGIN
|
|
101
|
+
IF p_key IS NULL OR p_key = '' THEN
|
|
102
|
+
RAISE EXCEPTION 'plg_emit_event: p_key is required'
|
|
103
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
104
|
+
END IF;
|
|
105
|
+
|
|
106
|
+
SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
|
|
107
|
+
|
|
108
|
+
IF v_tenant IS NULL THEN
|
|
109
|
+
-- A fact filed under the wrong tenant is worse than a fact not filed: it is
|
|
110
|
+
-- readable by people it does not belong to and it triggers their automations.
|
|
111
|
+
-- With more than one membership and no explicit tenant there is no answer,
|
|
112
|
+
-- only a guess, so refuse.
|
|
113
|
+
IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
|
|
114
|
+
RAISE EXCEPTION 'plg_emit_event: cannot resolve tenant for % — pass p_tenant_id', p_key
|
|
115
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
116
|
+
END IF;
|
|
117
|
+
v_tenant := v_tenants[1];
|
|
118
|
+
END IF;
|
|
119
|
+
|
|
120
|
+
-- SECURITY DEFINER runs as the owner and therefore bypasses the INSERT policy
|
|
121
|
+
-- above, so the membership check it would have made has to happen here — the
|
|
122
|
+
-- same defect 026 fixed on audit_logs. No auth.uid() means service_role or a
|
|
123
|
+
-- migration, i.e. the server, which legitimately writes for any tenant.
|
|
124
|
+
IF auth.uid() IS NOT NULL AND NOT (v_tenant = ANY (COALESCE(v_tenants, '{}'::uuid[]))) THEN
|
|
125
|
+
RAISE EXCEPTION 'plg_emit_event: caller is not a member of tenant %', v_tenant
|
|
126
|
+
USING ERRCODE = 'insufficient_privilege';
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
BEGIN
|
|
130
|
+
INSERT INTO public.plg_event_log
|
|
131
|
+
(tenant_id, event_key, payload, actor_id, subject_type, subject_id, event_id)
|
|
132
|
+
VALUES
|
|
133
|
+
(v_tenant, p_key, COALESCE(p_payload, '{}'::jsonb), auth.uid(),
|
|
134
|
+
p_subject_type, p_subject_id, p_event_id)
|
|
135
|
+
ON CONFLICT (event_id) WHERE event_id IS NOT NULL DO NOTHING
|
|
136
|
+
RETURNING id INTO v_id;
|
|
137
|
+
EXCEPTION WHEN OTHERS THEN
|
|
138
|
+
-- The write that caused the fact already succeeded. Losing the ledger row is
|
|
139
|
+
-- bad; taking the customer's saved booking down with it is worse.
|
|
140
|
+
RAISE WARNING '[plg_emit_event] % not recorded: %', p_key, SQLERRM;
|
|
141
|
+
RETURN NULL;
|
|
142
|
+
END;
|
|
143
|
+
|
|
144
|
+
RETURN v_id; -- NULL when p_event_id was already recorded.
|
|
145
|
+
END;
|
|
146
|
+
$fn$;
|
|
147
|
+
|
|
148
|
+
REVOKE ALL ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid) FROM public;
|
|
149
|
+
REVOKE ALL ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid) FROM anon;
|
|
150
|
+
GRANT EXECUTE ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid)
|
|
151
|
+
TO authenticated, service_role;
|
|
152
|
+
|
|
153
|
+
-- ── The generic row trigger ─────────────────────────────────────────────────
|
|
154
|
+
-- The payload carries id, display name and entity key — never the row. The whole
|
|
155
|
+
-- row would put CPF, phone and address inside every webhook by default, and
|
|
156
|
+
-- "send everything" is not a decision to make on behalf of whoever installed an
|
|
157
|
+
-- integration; whoever needs the rest fetches it by id. Same envelope the
|
|
158
|
+
-- browser store emits (see the note in core/src/entity/events.ts), plus
|
|
159
|
+
-- `source`, which is how you tell a trigger fact from a store fact when a table
|
|
160
|
+
-- is written by both.
|
|
161
|
+
CREATE OR REPLACE FUNCTION public.plg_emit_row_event()
|
|
162
|
+
RETURNS trigger
|
|
163
|
+
LANGUAGE plpgsql
|
|
164
|
+
SECURITY DEFINER
|
|
165
|
+
SET search_path = public
|
|
166
|
+
AS $fn$
|
|
167
|
+
DECLARE
|
|
168
|
+
v_row jsonb;
|
|
169
|
+
v_action text;
|
|
170
|
+
BEGIN
|
|
171
|
+
IF TG_OP = 'DELETE' THEN
|
|
172
|
+
v_row := to_jsonb(OLD);
|
|
173
|
+
v_action := 'deleted';
|
|
174
|
+
ELSIF TG_OP = 'UPDATE' THEN
|
|
175
|
+
-- A touch that changed nothing is not a fact. Without this, any bulk
|
|
176
|
+
-- re-save floods the ledger with "updated" and buries the real ones.
|
|
177
|
+
IF OLD IS NOT DISTINCT FROM NEW THEN
|
|
178
|
+
RETURN NULL;
|
|
179
|
+
END IF;
|
|
180
|
+
v_row := to_jsonb(NEW);
|
|
181
|
+
v_action := 'updated';
|
|
182
|
+
ELSE
|
|
183
|
+
v_row := to_jsonb(NEW);
|
|
184
|
+
v_action := 'created';
|
|
185
|
+
END IF;
|
|
186
|
+
|
|
187
|
+
BEGIN
|
|
188
|
+
PERFORM public.plg_emit_event(
|
|
189
|
+
format('registry.%s.%s', TG_ARGV[0], v_action),
|
|
190
|
+
jsonb_build_object(
|
|
191
|
+
'record_id', v_row->>'id',
|
|
192
|
+
-- Mirrors displayName() in createCrudStore: an event without a readable
|
|
193
|
+
-- name reads as "record" in the timeline.
|
|
194
|
+
'record_name', COALESCE(v_row->>'name', v_row->>'title', v_row->>'full_name', v_row->>'id'),
|
|
195
|
+
'entity_key', TG_ARGV[0],
|
|
196
|
+
'source', 'database'
|
|
197
|
+
),
|
|
198
|
+
TG_ARGV[0],
|
|
199
|
+
v_row->>'id',
|
|
200
|
+
NULLIF(v_row->>'tenant_id', '')::uuid,
|
|
201
|
+
NULL
|
|
202
|
+
);
|
|
203
|
+
EXCEPTION WHEN OTHERS THEN
|
|
204
|
+
-- AFTER trigger or not, an exception here would roll the row's write back.
|
|
205
|
+
-- Observability must never be able to reject a customer's data.
|
|
206
|
+
RAISE WARNING '[plg_emit_row_event] % on %: %', v_action, TG_TABLE_NAME, SQLERRM;
|
|
207
|
+
END;
|
|
208
|
+
|
|
209
|
+
RETURN NULL;
|
|
210
|
+
END;
|
|
211
|
+
$fn$;
|
|
212
|
+
|
|
213
|
+
REVOKE ALL ON FUNCTION public.plg_emit_row_event() FROM public;
|
|
214
|
+
REVOKE ALL ON FUNCTION public.plg_emit_row_event() FROM anon;
|
|
215
|
+
|
|
216
|
+
-- ── plg_attach_event_source ─────────────────────────────────────────────────
|
|
217
|
+
-- Implements PluginEventSource (core/src/types/plugins.ts): table, entityKey,
|
|
218
|
+
-- actions. Not granted to `authenticated`: it is SECURITY DEFINER and takes an
|
|
219
|
+
-- arbitrary regclass, so an exposed version would let any signed-in user put a
|
|
220
|
+
-- trigger on any table in the pool.
|
|
221
|
+
CREATE OR REPLACE FUNCTION public.plg_attach_event_source(
|
|
222
|
+
p_table regclass,
|
|
223
|
+
p_entity_key text,
|
|
224
|
+
p_actions text[] DEFAULT ARRAY['created', 'updated', 'deleted']
|
|
225
|
+
)
|
|
226
|
+
RETURNS void
|
|
227
|
+
LANGUAGE plpgsql
|
|
228
|
+
SECURITY DEFINER
|
|
229
|
+
SET search_path = public
|
|
230
|
+
AS $fn$
|
|
231
|
+
DECLARE
|
|
232
|
+
v_actions text[] := COALESCE(NULLIF(p_actions, '{}'::text[]), ARRAY['created', 'updated', 'deleted']);
|
|
233
|
+
v_action text;
|
|
234
|
+
v_ops text[] := '{}';
|
|
235
|
+
v_trigger text;
|
|
236
|
+
BEGIN
|
|
237
|
+
IF p_entity_key IS NULL OR p_entity_key = '' THEN
|
|
238
|
+
RAISE EXCEPTION 'plg_attach_event_source: p_entity_key is required'
|
|
239
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
240
|
+
END IF;
|
|
241
|
+
|
|
242
|
+
FOREACH v_action IN ARRAY v_actions LOOP
|
|
243
|
+
CASE v_action
|
|
244
|
+
WHEN 'created' THEN v_ops := array_append(v_ops, 'INSERT');
|
|
245
|
+
WHEN 'updated' THEN v_ops := array_append(v_ops, 'UPDATE');
|
|
246
|
+
WHEN 'deleted' THEN v_ops := array_append(v_ops, 'DELETE');
|
|
247
|
+
ELSE RAISE EXCEPTION 'plg_attach_event_source: unknown action %', v_action
|
|
248
|
+
USING ERRCODE = 'invalid_parameter_value';
|
|
249
|
+
END CASE;
|
|
250
|
+
END LOOP;
|
|
251
|
+
|
|
252
|
+
-- Derived from the table and not from the entity key: one table gets one
|
|
253
|
+
-- trigger, so re-attaching replaces instead of stacking a second emitter.
|
|
254
|
+
v_trigger := left(replace(replace(p_table::text, '.', '_'), '"', ''), 48) || '_emit_events';
|
|
255
|
+
|
|
256
|
+
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_trigger, p_table::text);
|
|
257
|
+
EXECUTE format(
|
|
258
|
+
'CREATE TRIGGER %I AFTER %s ON %s FOR EACH ROW EXECUTE FUNCTION public.plg_emit_row_event(%L)',
|
|
259
|
+
v_trigger, array_to_string(v_ops, ' OR '), p_table::text, p_entity_key
|
|
260
|
+
);
|
|
261
|
+
END;
|
|
262
|
+
$fn$;
|
|
263
|
+
|
|
264
|
+
REVOKE ALL ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) FROM public;
|
|
265
|
+
REVOKE ALL ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) FROM anon;
|
|
266
|
+
GRANT EXECUTE ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) TO service_role;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 028_tenant_scoped_token.sql — a connector's reach comes from its token.
|
|
3
|
+
--
|
|
4
|
+
-- THE EXPOSURE. A pool is ONE Supabase project serving N tenants:
|
|
5
|
+
-- cluster-salon-br-01 (gphxclpkbtbucoqclbco) carries beauty-saas,
|
|
6
|
+
-- espaco-renova-rio and several real clinics in the same `public` schema. Every
|
|
7
|
+
-- plugin edge function builds its client with SUPABASE_SERVICE_ROLE_KEY, and
|
|
8
|
+
-- service_role bypasses RLS by definition — so a connector installed by ONE
|
|
9
|
+
-- clinic can read EVERY clinic in the cluster. No line of that code is wrong;
|
|
10
|
+
-- the credential simply has no tenant in it.
|
|
11
|
+
--
|
|
12
|
+
-- THE SEAM. `public.user_tenant_ids()` is the single function every RLS policy
|
|
13
|
+
-- in the product resolves through (~260 call sites in the SQL tree: db, shop,
|
|
14
|
+
-- courses, plugins). Teach it to honour a `tenant_id` claim and a token becomes a
|
|
15
|
+
-- tenant boundary — no policy is edited, no table is touched.
|
|
16
|
+
--
|
|
17
|
+
-- THIS IS HALF THE FEATURE. Nothing mints such a token yet: minting lives in
|
|
18
|
+
-- the platform repo (../fayz), not here. Until it does, no request carries the
|
|
19
|
+
-- claim, the claim branch is never taken, and the edge functions keep using
|
|
20
|
+
-- service_role — which is exactly why this file is safe to apply now. No edge
|
|
21
|
+
-- function in this repo is changed to expect a token that cannot be issued.
|
|
22
|
+
-- ============================================================================
|
|
23
|
+
|
|
24
|
+
-- ── public.user_tenant_ids() ────────────────────────────────────────────────
|
|
25
|
+
-- Signature, return type and volatility are unchanged (SETOF uuid, STABLE,
|
|
26
|
+
-- SECURITY DEFINER), so every inherited policy keeps compiling and planning as
|
|
27
|
+
-- it did. Only the body grows a branch.
|
|
28
|
+
--
|
|
29
|
+
-- WHERE THE CLAIM LIVES: top-level `tenant_id`, not `app_metadata.tenant_id`.
|
|
30
|
+
-- The repo reads top-level claims and only top-level ones today (022
|
|
31
|
+
-- `auth.jwt() ->> 'email'`; shop 0019/0023 `->> 'role'`), and GoTrue never puts
|
|
32
|
+
-- a top-level `tenant_id` on a session token — only a JWT we signed ourselves
|
|
33
|
+
-- can carry one. `app_metadata` would instead ride along on every session of
|
|
34
|
+
-- every user whose metadata was ever stamped, silently putting real people on
|
|
35
|
+
-- the narrowing branch and cutting a two-tenant user down to one. Supabase's
|
|
36
|
+
-- own guidance rules out `user_metadata` outright: the user can write it.
|
|
37
|
+
CREATE OR REPLACE FUNCTION public.user_tenant_ids()
|
|
38
|
+
RETURNS SETOF uuid
|
|
39
|
+
LANGUAGE sql STABLE SECURITY DEFINER
|
|
40
|
+
SET search_path = public, pg_temp
|
|
41
|
+
AS $$
|
|
42
|
+
WITH claim AS (
|
|
43
|
+
-- PRESENCE of the key and USABILITY of its value are separate questions.
|
|
44
|
+
-- A token that carries `tenant_id` has expressed intent to be narrowed; if
|
|
45
|
+
-- the value then fails to parse, the answer is no tenant, never the full
|
|
46
|
+
-- session handed back. Only an absent key falls through.
|
|
47
|
+
SELECT coalesce(jsonb_exists(auth.jwt(), 'tenant_id'), false) AS present,
|
|
48
|
+
auth.jwt() ->> 'tenant_id' AS raw
|
|
49
|
+
),
|
|
50
|
+
scoped AS (
|
|
51
|
+
-- Shape-checked instead of cast blindly: an RLS policy is not a place to
|
|
52
|
+
-- raise, so a junk value must resolve to no rows rather than an error.
|
|
53
|
+
SELECT raw::uuid AS tenant_id
|
|
54
|
+
FROM claim
|
|
55
|
+
WHERE raw ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
56
|
+
)
|
|
57
|
+
-- No claim at all → the pre-028 behaviour, unchanged. A token with neither a
|
|
58
|
+
-- claim nor auth.uid() therefore matches no member row and resolves to
|
|
59
|
+
-- nothing, which is the fail-closed answer.
|
|
60
|
+
SELECT tm.tenant_id
|
|
61
|
+
FROM public.tenant_members tm
|
|
62
|
+
WHERE tm.user_id = auth.uid()
|
|
63
|
+
AND NOT (SELECT present FROM claim)
|
|
64
|
+
UNION
|
|
65
|
+
-- Precedence: the claim NARROWS, it never widens. A signed-in session is the
|
|
66
|
+
-- stronger authority, so when auth.uid() is present the claim is intersected
|
|
67
|
+
-- with real membership rather than trusted — a token bearing someone else's
|
|
68
|
+
-- tenant reaches nothing. Only a session-less token (the connector) is scoped
|
|
69
|
+
-- by the claim alone, and that token can only exist if we signed it.
|
|
70
|
+
SELECT s.tenant_id
|
|
71
|
+
FROM scoped s
|
|
72
|
+
WHERE auth.uid() IS NULL
|
|
73
|
+
OR EXISTS (
|
|
74
|
+
SELECT 1 FROM public.tenant_members tm
|
|
75
|
+
WHERE tm.user_id = auth.uid() AND tm.tenant_id = s.tenant_id
|
|
76
|
+
);
|
|
77
|
+
$$;
|
|
78
|
+
|
|
79
|
+
-- ── The connector role ──────────────────────────────────────────────────────
|
|
80
|
+
-- What a connector may do becomes a property of the role its token lands in.
|
|
81
|
+
-- `fayz_connector` is NOT a service_role understudy: RLS applies to it in full,
|
|
82
|
+
-- and with the claim above that means exactly one tenant. Named for the `fayz_`
|
|
83
|
+
-- prefix the tree already uses for platform-owned objects (fayz_projects,
|
|
84
|
+
-- fayz_migration_ledger, fayz_global_search).
|
|
85
|
+
DO $$
|
|
86
|
+
BEGIN
|
|
87
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_connector') THEN
|
|
88
|
+
CREATE ROLE fayz_connector NOLOGIN NOINHERIT;
|
|
89
|
+
END IF;
|
|
90
|
+
END $$;
|
|
91
|
+
|
|
92
|
+
-- PostgREST SET ROLEs into the role named by the token's `role` claim, which it
|
|
93
|
+
-- can only do if the authenticator it connects as is a member. Guarded because
|
|
94
|
+
-- a bare Postgres (the migration bench) has no authenticator role.
|
|
95
|
+
DO $$
|
|
96
|
+
BEGIN
|
|
97
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticator') THEN
|
|
98
|
+
EXECUTE 'GRANT fayz_connector TO authenticator';
|
|
99
|
+
END IF;
|
|
100
|
+
END $$;
|
|
101
|
+
|
|
102
|
+
GRANT USAGE ON SCHEMA public TO fayz_connector;
|
|
103
|
+
|
|
104
|
+
-- NO DIRECT TABLE ACCESS, now and for tables added later. 008 grants tables to
|
|
105
|
+
-- authenticated/service_role only, so this is already true by omission — stated
|
|
106
|
+
-- explicitly because a silence is not a decision, and because a future blanket
|
|
107
|
+
-- grant would otherwise reach this role too. A connector reads through declared
|
|
108
|
+
-- functions or it does not read.
|
|
109
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM fayz_connector;
|
|
110
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM fayz_connector;
|
|
111
|
+
|
|
112
|
+
-- The RPC surface is opt-in per function, written next to the function that
|
|
113
|
+
-- opens it (`GRANT EXECUTE ON FUNCTION ... TO fayz_connector`), the way
|
|
114
|
+
-- 015/018/022/024 grant themselves. This one is granted here because it is how
|
|
115
|
+
-- a connector answers "which tenant am I?", and it can only ever return the
|
|
116
|
+
-- caller's own scope.
|
|
117
|
+
--
|
|
118
|
+
-- What is NOT done here, deliberately: PUBLIC keeps its default EXECUTE on this
|
|
119
|
+
-- function. RLS policy expressions run as the QUERYING role, so revoking it
|
|
120
|
+
-- would turn anon's storefront catalogue reads into "permission denied for
|
|
121
|
+
-- function user_tenant_ids" instead of zero rows — 009 revoked anon's explicit
|
|
122
|
+
-- grant and anon still works today precisely because PUBLIC's carries it. The
|
|
123
|
+
-- same default means fayz_connector can reach public functions it was never
|
|
124
|
+
-- granted; that is a pre-existing repo-wide condition this file neither creates
|
|
125
|
+
-- nor widens, and its ceiling is RLS plus the claim above.
|
|
126
|
+
GRANT EXECUTE ON FUNCTION public.user_tenant_ids()
|
|
127
|
+
TO authenticated, service_role, fayz_connector;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 029_connections.sql — one shape for every connector.
|
|
3
|
+
--
|
|
4
|
+
-- Four connectors exist today and each brought its own pair of tables
|
|
5
|
+
-- (plg_marketing_ranklayer_integrations, bank_integrations, calendar_integrations,
|
|
6
|
+
-- + a *_sync_log each). They are the same table under four names: tenant, on/off,
|
|
7
|
+
-- settings, last sync. At 100 connectors that is 200 tables nobody can audit.
|
|
8
|
+
--
|
|
9
|
+
-- Two tables replace the pair. They live in packages/db and not in a plugin
|
|
10
|
+
-- because a connector must be able to ship with a ConnectorDefinition and no SQL
|
|
11
|
+
-- at all — a table that only exists when some plugin happens to be installed
|
|
12
|
+
-- cannot be that.
|
|
13
|
+
--
|
|
14
|
+
-- The two levels the shape encodes, and they do not flatten (FAY-1376 item 1):
|
|
15
|
+
-- connection = ONE account at the provider (one grant of credentials)
|
|
16
|
+
-- stream = one resource inside it (a calendar, a bank account)
|
|
17
|
+
-- Two Google accounts are two connections; two calendars are two streams of one
|
|
18
|
+
-- connection. `instance_key` is what separates the first case; `cursors` is
|
|
19
|
+
-- keyed by the second.
|
|
20
|
+
--
|
|
21
|
+
-- SUPERSEDES nothing yet (still true 2026-08-12): the live pool's connections
|
|
22
|
+
-- all sit on bespoke tables and this table is EMPTY there. The bespoke tables
|
|
23
|
+
-- die one ticket each — bank_integrations (FAY-1360), the RankLayer leftovers
|
|
24
|
+
-- (FAY-1391), and plg_calendar_integrations LAST (FAY-1359): it holds a real
|
|
25
|
+
-- clinic's OAuth refresh token, whose destination is the platform's encrypted
|
|
26
|
+
-- ConnectorConnection store (FAY-1388), never a column here. Adding a token
|
|
27
|
+
-- column to this table instead would put every provider's token for every
|
|
28
|
+
-- tenant of a shared pool in one place: strictly worse than today's dispersion.
|
|
29
|
+
-- ============================================================================
|
|
30
|
+
|
|
31
|
+
-- ── The connection ──────────────────────────────────────────────────────────
|
|
32
|
+
-- No credential column, by construction — a connector cannot store a secret here
|
|
33
|
+
-- because there is nowhere to put one; convention failed three times already.
|
|
34
|
+
CREATE TABLE IF NOT EXISTS public.plg_connections (
|
|
35
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
36
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
37
|
+
connector_id text NOT NULL,
|
|
38
|
+
instance_key text NOT NULL DEFAULT '', -- bank account, Bling company, gateway instance
|
|
39
|
+
display_name text,
|
|
40
|
+
active boolean NOT NULL DEFAULT true, -- the tenant's switch
|
|
41
|
+
status text NOT NULL DEFAULT 'pending' -- health, observed server-side
|
|
42
|
+
CHECK (status IN ('pending', 'connected', 'error', 'revoked')),
|
|
43
|
+
status_detail text,
|
|
44
|
+
settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- declared keys only; never a credential
|
|
45
|
+
provider_account_key text,
|
|
46
|
+
cursors jsonb NOT NULL DEFAULT '{}'::jsonb, -- {stream: cursor}
|
|
47
|
+
last_sync_at timestamptz,
|
|
48
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
49
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
50
|
+
UNIQUE (tenant_id, connector_id, instance_key)
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
-- Webhook routing: the provider announces an account, not a tenant. Partial so
|
|
54
|
+
-- the connectors that never learn an account key (api-key ones) are unaffected.
|
|
55
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_connections_account_key
|
|
56
|
+
ON public.plg_connections (connector_id, provider_account_key)
|
|
57
|
+
WHERE provider_account_key IS NOT NULL;
|
|
58
|
+
|
|
59
|
+
COMMENT ON TABLE public.plg_connections IS
|
|
60
|
+
'One tenant''s connection to one account at one provider. Has NO credential '
|
|
61
|
+
'column by construction: secrets belong to the platform credential store, '
|
|
62
|
+
'never to a row a connector''s own browser code can write.';
|
|
63
|
+
|
|
64
|
+
-- ── The run ────────────────────────────────────────────────────────────────
|
|
65
|
+
-- One row per execution of the SYNC plane. An invocation of the `request` plane
|
|
66
|
+
-- (a shipping quote, a checkout session) is NOT a run: it would be the largest
|
|
67
|
+
-- table in the pool within weeks, it is issued for an `anon` buyer with no
|
|
68
|
+
-- tenant-scoped write path, and it has no fetched/written/cursor to record.
|
|
69
|
+
CREATE TABLE IF NOT EXISTS public.plg_sync_runs (
|
|
70
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
71
|
+
tenant_id uuid NOT NULL,
|
|
72
|
+
connection_id uuid NOT NULL REFERENCES public.plg_connections(id) ON DELETE CASCADE,
|
|
73
|
+
connector_id text NOT NULL,
|
|
74
|
+
direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
|
|
75
|
+
-- `trigger_kind`, never `trigger`: the reserved word already cost the banking
|
|
76
|
+
-- plugin one migration.
|
|
77
|
+
trigger_kind text NOT NULL CHECK (trigger_kind IN ('on-write', 'scheduled', 'manual', 'webhook')),
|
|
78
|
+
status text NOT NULL CHECK (status IN ('running', 'success', 'partial', 'error')),
|
|
79
|
+
stream text,
|
|
80
|
+
fetched integer NOT NULL DEFAULT 0,
|
|
81
|
+
written integer NOT NULL DEFAULT 0,
|
|
82
|
+
stats jsonb NOT NULL DEFAULT '{}'::jsonb, -- duplicates, discovered, period_from/to
|
|
83
|
+
cursor text,
|
|
84
|
+
correlation_id uuid,
|
|
85
|
+
error text,
|
|
86
|
+
started_at timestamptz NOT NULL DEFAULT now(),
|
|
87
|
+
finished_at timestamptz
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_plg_sync_runs_read
|
|
91
|
+
ON public.plg_sync_runs (connection_id, started_at DESC);
|
|
92
|
+
|
|
93
|
+
-- ── RLS and the default-privilege trap ─────────────────────────────────────
|
|
94
|
+
-- 011 closed `anon` on every table in the schema and for future ones. It did
|
|
95
|
+
-- NOT close `authenticated`, which is still born with the full set on any new
|
|
96
|
+
-- public table. Here that would be every tenant reading every tenant's
|
|
97
|
+
-- provider_account_key — the bank-account instance key of every clinic in the
|
|
98
|
+
-- pool. So both tables are stripped first and granted back only what they need.
|
|
99
|
+
REVOKE ALL ON public.plg_connections FROM anon, authenticated;
|
|
100
|
+
REVOKE ALL ON public.plg_sync_runs FROM anon, authenticated;
|
|
101
|
+
|
|
102
|
+
ALTER TABLE public.plg_connections ENABLE ROW LEVEL SECURITY;
|
|
103
|
+
ALTER TABLE public.plg_sync_runs ENABLE ROW LEVEL SECURITY;
|
|
104
|
+
|
|
105
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_connections TO authenticated;
|
|
106
|
+
|
|
107
|
+
-- SELECT only. A history the application can write is not a history: any member
|
|
108
|
+
-- could otherwise forge "sync succeeded" on a shared audit table. Runs are
|
|
109
|
+
-- written server-side (service_role), the same rule plg_event_log follows.
|
|
110
|
+
GRANT SELECT ON public.plg_sync_runs TO authenticated;
|
|
111
|
+
|
|
112
|
+
DROP POLICY IF EXISTS plg_connections_member_read ON public.plg_connections;
|
|
113
|
+
CREATE POLICY plg_connections_member_read ON public.plg_connections
|
|
114
|
+
FOR SELECT TO authenticated
|
|
115
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
116
|
+
|
|
117
|
+
DROP POLICY IF EXISTS plg_connections_member_insert ON public.plg_connections;
|
|
118
|
+
CREATE POLICY plg_connections_member_insert ON public.plg_connections
|
|
119
|
+
FOR INSERT TO authenticated
|
|
120
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
121
|
+
|
|
122
|
+
DROP POLICY IF EXISTS plg_connections_member_update ON public.plg_connections;
|
|
123
|
+
CREATE POLICY plg_connections_member_update ON public.plg_connections
|
|
124
|
+
FOR UPDATE TO authenticated
|
|
125
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()))
|
|
126
|
+
WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
127
|
+
|
|
128
|
+
DROP POLICY IF EXISTS plg_connections_member_delete ON public.plg_connections;
|
|
129
|
+
CREATE POLICY plg_connections_member_delete ON public.plg_connections
|
|
130
|
+
FOR DELETE TO authenticated
|
|
131
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
132
|
+
|
|
133
|
+
DROP POLICY IF EXISTS plg_sync_runs_member_read ON public.plg_sync_runs;
|
|
134
|
+
CREATE POLICY plg_sync_runs_member_read ON public.plg_sync_runs
|
|
135
|
+
FOR SELECT TO authenticated
|
|
136
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
137
|
+
|
|
138
|
+
-- No INSERT/UPDATE/DELETE policy on plg_sync_runs, deliberately: the missing
|
|
139
|
+
-- grant above and the missing policy here have to agree, or the next person to
|
|
140
|
+
-- "fix the grant" reopens it.
|
|
141
|
+
|
|
142
|
+
-- ── Retention ──────────────────────────────────────────────────────────────
|
|
143
|
+
-- Append-only, N tenants per pool, a scheduled pull every 10 minutes. Every
|
|
144
|
+
-- reader wants the last 20 rows; the table wants millions. The prune ships with
|
|
145
|
+
-- the table and not in a later ticket, because by the time it is a ticket the
|
|
146
|
+
-- pool already has the rows.
|
|
147
|
+
--
|
|
148
|
+
-- A run is deleted only when it is BOTH outside the newest N of its connection
|
|
149
|
+
-- AND older than the window. Either test alone is wrong: newest-N alone erases
|
|
150
|
+
-- the morning while someone is debugging it, and age alone empties the panel of
|
|
151
|
+
-- a connector that syncs once a quarter.
|
|
152
|
+
CREATE OR REPLACE FUNCTION public.plg_prune_sync_runs(
|
|
153
|
+
p_keep_per_connection integer DEFAULT 50,
|
|
154
|
+
p_keep_days integer DEFAULT 30
|
|
155
|
+
)
|
|
156
|
+
RETURNS integer
|
|
157
|
+
LANGUAGE plpgsql
|
|
158
|
+
SECURITY DEFINER
|
|
159
|
+
SET search_path = public
|
|
160
|
+
AS $fn$
|
|
161
|
+
DECLARE
|
|
162
|
+
v_rows integer;
|
|
163
|
+
BEGIN
|
|
164
|
+
WITH ranked AS (
|
|
165
|
+
SELECT id,
|
|
166
|
+
row_number() OVER (PARTITION BY connection_id ORDER BY started_at DESC) AS rn
|
|
167
|
+
FROM public.plg_sync_runs
|
|
168
|
+
)
|
|
169
|
+
DELETE FROM public.plg_sync_runs r
|
|
170
|
+
USING ranked
|
|
171
|
+
WHERE r.id = ranked.id
|
|
172
|
+
AND ranked.rn > GREATEST(p_keep_per_connection, 1)
|
|
173
|
+
AND r.started_at < now() - make_interval(days => GREATEST(p_keep_days, 0));
|
|
174
|
+
GET DIAGNOSTICS v_rows = ROW_COUNT;
|
|
175
|
+
RETURN v_rows;
|
|
176
|
+
END;
|
|
177
|
+
$fn$;
|
|
178
|
+
|
|
179
|
+
REVOKE ALL ON FUNCTION public.plg_prune_sync_runs(integer, integer) FROM PUBLIC;
|
|
180
|
+
REVOKE ALL ON FUNCTION public.plg_prune_sync_runs(integer, integer) FROM anon, authenticated;
|
|
181
|
+
GRANT EXECUTE ON FUNCTION public.plg_prune_sync_runs(integer, integer) TO service_role;
|
|
182
|
+
|
|
183
|
+
COMMENT ON FUNCTION public.plg_prune_sync_runs(integer, integer) IS
|
|
184
|
+
'Retention for plg_sync_runs: deletes runs that are both outside the newest N '
|
|
185
|
+
'of their connection and older than the window. No pool runs pg_cron, so the '
|
|
186
|
+
'caller is the sync ingress — it prunes the connection it just wrote.';
|