@fayz-ai/db 0.12.0 → 0.13.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/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/040_resource_grants.sql +2 -2
- package/migrations/041_scoped_columns.sql +2 -2
- package/migrations/042_unit_scope_policies.sql +1 -1
- package/migrations/043_view_invoker.sql +12 -3
- package/migrations/044_unit_member_facts.sql +1 -1
- package/migrations/045_unit_entry.sql +1 -1
- package/migrations/046_membership_visible_to_members.sql +1 -1
- package/migrations/047_tasks.sql +4 -4
- package/migrations/048_every_login_is_a_person.sql +1 -1
- package/migrations/049_bookable_people.sql +1 -1
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 161_a_tenant_leaves_with_its_data.sql — the two halves 155 left as settings
|
|
3
|
+
-- and never built (#284).
|
|
4
|
+
--
|
|
5
|
+
-- 155 gave churn a mechanism: freeze for N days, then a recorded purge, with the
|
|
6
|
+
-- same machine answering an LGPD erasure one level down. It named three things
|
|
7
|
+
-- as configuration and shipped only the first — N is
|
|
8
|
+
-- `tenants.settings.retention.freeze_days`. The other two are here.
|
|
9
|
+
--
|
|
10
|
+
-- ── Export on exit ─────────────────────────────────────────────────────────
|
|
11
|
+
-- A customer who cancels is owed their data, and the LGPD says so in words
|
|
12
|
+
-- (art. 18, V: portabilidade). Building it at purge time would be too late: the
|
|
13
|
+
-- account is frozen for N days precisely so somebody can change their mind or
|
|
14
|
+
-- take a copy, and a copy that only exists at the moment of deletion is not a
|
|
15
|
+
-- copy, it is a race.
|
|
16
|
+
--
|
|
17
|
+
-- So the export is a REQUEST, made while the data is still there, drained by
|
|
18
|
+
-- the same worker that drains everything else. It writes nothing itself: it
|
|
19
|
+
-- lists what a tenant holds, table by table, and the worker turns that into
|
|
20
|
+
-- files. A database is a bad file server and this file does not pretend
|
|
21
|
+
-- otherwise.
|
|
22
|
+
--
|
|
23
|
+
-- ── Fiscal carve-outs ──────────────────────────────────────────────────────
|
|
24
|
+
-- The purge in 155 is deliberately dumb: every table with `tenant_id`, swept
|
|
25
|
+
-- until a pass moves nothing. That is what makes it complete without a
|
|
26
|
+
-- hand-ordered list that stops being complete the day somebody adds a table.
|
|
27
|
+
--
|
|
28
|
+
-- It is also what makes it wrong in Brazil. A nota fiscal, its cancellation and
|
|
29
|
+
-- the ledger behind them must survive five years whatever the customer asks —
|
|
30
|
+
-- and an erasure request cannot override a retention obligation, in the LGPD's
|
|
31
|
+
-- own text (art. 16, I). So the sweep learns one exception, declared as rows
|
|
32
|
+
-- rather than written into the function: WHICH tables, for WHICH reason, until
|
|
33
|
+
-- WHEN. A carve-out with no expiry and no citation is how "we keep everything"
|
|
34
|
+
-- gets called compliance.
|
|
35
|
+
-- ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
-- §1 What the law makes us keep
|
|
39
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS app.retention_carve_outs (
|
|
42
|
+
table_name text PRIMARY KEY,
|
|
43
|
+
reason text NOT NULL,
|
|
44
|
+
-- The rule that requires it, so a reviewer can check rather than trust.
|
|
45
|
+
authority text NOT NULL,
|
|
46
|
+
-- How long after the purge the rows must still exist. NULL is not allowed:
|
|
47
|
+
-- "forever" is a decision somebody has to write down as a number.
|
|
48
|
+
keep_years integer NOT NULL CHECK (keep_years BETWEEN 1 AND 30),
|
|
49
|
+
jurisdiction text NOT NULL DEFAULT 'BR',
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
COMMENT ON TABLE app.retention_carve_outs IS
|
|
54
|
+
'Tables a tenant purge must NOT empty, with the rule that requires it and for how long (161, #284). Rows rather than code: a carve-out is a legal fact that differs per market and changes without a deploy. `authority` and `keep_years` are NOT NULL because a carve-out nobody can check is how "we keep everything" gets called compliance.';
|
|
55
|
+
|
|
56
|
+
ALTER TABLE app.retention_carve_outs ENABLE ROW LEVEL SECURITY;
|
|
57
|
+
DROP POLICY IF EXISTS retention_carve_outs_read ON app.retention_carve_outs;
|
|
58
|
+
CREATE POLICY retention_carve_outs_read ON app.retention_carve_outs
|
|
59
|
+
FOR SELECT TO authenticated, service_role USING (true);
|
|
60
|
+
GRANT SELECT ON app.retention_carve_outs TO authenticated, service_role;
|
|
61
|
+
GRANT ALL ON app.retention_carve_outs TO service_role;
|
|
62
|
+
|
|
63
|
+
CREATE OR REPLACE FUNCTION public.register_retention_carve_out(
|
|
64
|
+
p_table text, p_reason text, p_authority text, p_keep_years integer, p_jurisdiction text DEFAULT 'BR'
|
|
65
|
+
) RETURNS void
|
|
66
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
67
|
+
AS $$
|
|
68
|
+
BEGIN
|
|
69
|
+
IF coalesce(btrim(p_authority), '') = '' THEN
|
|
70
|
+
RAISE EXCEPTION 'register_retention_carve_out: % needs the rule that requires it', p_table
|
|
71
|
+
USING ERRCODE = '22023',
|
|
72
|
+
HINT = 'Name the article, the statute or the contract clause. A carve-out nobody can check is not a carve-out.';
|
|
73
|
+
END IF;
|
|
74
|
+
INSERT INTO app.retention_carve_outs (table_name, reason, authority, keep_years, jurisdiction)
|
|
75
|
+
VALUES (p_table, p_reason, p_authority, p_keep_years, p_jurisdiction)
|
|
76
|
+
ON CONFLICT (table_name) DO UPDATE
|
|
77
|
+
SET reason = EXCLUDED.reason, authority = EXCLUDED.authority,
|
|
78
|
+
keep_years = EXCLUDED.keep_years, jurisdiction = EXCLUDED.jurisdiction;
|
|
79
|
+
END $$;
|
|
80
|
+
|
|
81
|
+
REVOKE ALL ON FUNCTION public.register_retention_carve_out(text, text, text, integer, text) FROM public, anon, authenticated;
|
|
82
|
+
GRANT EXECUTE ON FUNCTION public.register_retention_carve_out(text, text, text, integer, text) TO service_role;
|
|
83
|
+
|
|
84
|
+
-- The carve-outs themselves are declared by the plugin that OWNS the table.
|
|
85
|
+
-- They were here first, and that was wrong twice over: the base chain runs
|
|
86
|
+
-- before any plugin's, so `plg_financial_movements` does not exist yet and the
|
|
87
|
+
-- guard skipped every one of them — and a retention obligation is a fact about
|
|
88
|
+
-- a domain, which is the plugin's to state. plugin-financial 036 declares the
|
|
89
|
+
-- three Brazilian ones. What stays here is the register and the rule.
|
|
90
|
+
|
|
91
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
92
|
+
-- §2 What a tenant would take with it
|
|
93
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS app.tenant_exports (
|
|
96
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
97
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
98
|
+
requested_by uuid,
|
|
99
|
+
status text NOT NULL DEFAULT 'requested'
|
|
100
|
+
CHECK (status IN ('requested', 'running', 'ready', 'failed', 'expired')),
|
|
101
|
+
-- Where the worker put it. A pointer, like every other file in this platform
|
|
102
|
+
-- since 152: the bytes are storage's, the register is the base's.
|
|
103
|
+
storage_path text,
|
|
104
|
+
row_count bigint,
|
|
105
|
+
byte_size bigint,
|
|
106
|
+
error text,
|
|
107
|
+
-- A download link that lives forever is a data breach with a URL.
|
|
108
|
+
expires_at timestamptz,
|
|
109
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
110
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
COMMENT ON TABLE app.tenant_exports IS
|
|
114
|
+
'Requests for a copy of everything a tenant holds (161, #284) — the portability the LGPD names (art. 18, V), made while the data still exists rather than at the moment of deletion.';
|
|
115
|
+
|
|
116
|
+
CREATE INDEX IF NOT EXISTS tenant_exports_pending ON app.tenant_exports (created_at)
|
|
117
|
+
WHERE status IN ('requested', 'running');
|
|
118
|
+
|
|
119
|
+
ALTER TABLE app.tenant_exports ENABLE ROW LEVEL SECURITY;
|
|
120
|
+
DROP POLICY IF EXISTS tenant_exports_read ON app.tenant_exports;
|
|
121
|
+
CREATE POLICY tenant_exports_read ON app.tenant_exports
|
|
122
|
+
FOR SELECT TO authenticated
|
|
123
|
+
USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
124
|
+
GRANT SELECT ON app.tenant_exports TO authenticated;
|
|
125
|
+
GRANT ALL ON app.tenant_exports TO service_role;
|
|
126
|
+
|
|
127
|
+
DROP TRIGGER IF EXISTS tenant_exports_updated_at ON app.tenant_exports;
|
|
128
|
+
CREATE TRIGGER tenant_exports_updated_at BEFORE UPDATE ON app.tenant_exports
|
|
129
|
+
FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
|
|
130
|
+
|
|
131
|
+
CREATE OR REPLACE FUNCTION public.tenant_export_request(p_tenant uuid DEFAULT NULL)
|
|
132
|
+
RETURNS jsonb
|
|
133
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
134
|
+
AS $$
|
|
135
|
+
DECLARE
|
|
136
|
+
v_tenant uuid := coalesce(p_tenant, app.current_tenant_id());
|
|
137
|
+
v_id uuid;
|
|
138
|
+
BEGIN
|
|
139
|
+
IF v_tenant IS NULL THEN
|
|
140
|
+
RAISE EXCEPTION 'tenant_export_request: no tenant in scope' USING ERRCODE = '22023';
|
|
141
|
+
END IF;
|
|
142
|
+
IF auth.uid() IS NOT NULL AND NOT app.has_permission('settings.manage') THEN
|
|
143
|
+
RAISE EXCEPTION 'tenant_export_request: requires settings.manage' USING ERRCODE = '42501';
|
|
144
|
+
END IF;
|
|
145
|
+
|
|
146
|
+
-- One pending request at a time. A second one would produce a second copy of
|
|
147
|
+
-- everything, which is a cost and a second thing to expire.
|
|
148
|
+
SELECT e.id INTO v_id FROM app.tenant_exports e
|
|
149
|
+
WHERE e.tenant_id = v_tenant AND e.status IN ('requested', 'running')
|
|
150
|
+
ORDER BY e.created_at DESC LIMIT 1;
|
|
151
|
+
IF v_id IS NOT NULL THEN
|
|
152
|
+
RETURN jsonb_build_object('export_id', v_id, 'status', 'already_requested');
|
|
153
|
+
END IF;
|
|
154
|
+
|
|
155
|
+
INSERT INTO app.tenant_exports (tenant_id, requested_by, expires_at)
|
|
156
|
+
VALUES (v_tenant, auth.uid(), now() + interval '14 days')
|
|
157
|
+
RETURNING id INTO v_id;
|
|
158
|
+
|
|
159
|
+
RETURN jsonb_build_object('export_id', v_id, 'status', 'requested');
|
|
160
|
+
END $$;
|
|
161
|
+
|
|
162
|
+
COMMENT ON FUNCTION public.tenant_export_request(uuid) IS
|
|
163
|
+
'Asks for a copy of everything this tenant holds (161). Enqueues; the worker writes the files, because a database is a bad file server.';
|
|
164
|
+
|
|
165
|
+
GRANT EXECUTE ON FUNCTION public.tenant_export_request(uuid) TO authenticated, service_role;
|
|
166
|
+
|
|
167
|
+
-- What the worker reads: every tenant-scoped table and how many rows it holds
|
|
168
|
+
-- for this tenant. Discovered rather than listed, for the same reason the purge
|
|
169
|
+
-- sweeps rather than following a list — a hand-written manifest stops being
|
|
170
|
+
-- complete the day somebody adds a table, and an incomplete export is a
|
|
171
|
+
-- portability request answered wrongly.
|
|
172
|
+
CREATE OR REPLACE FUNCTION public.tenant_export_manifest(p_tenant uuid)
|
|
173
|
+
RETURNS TABLE (table_name text, row_count bigint, carved_out boolean)
|
|
174
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
175
|
+
AS $$
|
|
176
|
+
DECLARE
|
|
177
|
+
v_tbl record;
|
|
178
|
+
v_n bigint;
|
|
179
|
+
BEGIN
|
|
180
|
+
FOR v_tbl IN
|
|
181
|
+
SELECT c.relname::text AS name
|
|
182
|
+
FROM pg_class c
|
|
183
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
184
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND a.attnum > 0
|
|
185
|
+
WHERE n.nspname = 'public' AND c.relkind = 'r' AND NOT a.attisdropped
|
|
186
|
+
ORDER BY c.relname
|
|
187
|
+
LOOP
|
|
188
|
+
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id = $1', v_tbl.name)
|
|
189
|
+
INTO v_n USING p_tenant;
|
|
190
|
+
IF v_n = 0 THEN CONTINUE; END IF;
|
|
191
|
+
table_name := v_tbl.name;
|
|
192
|
+
row_count := v_n;
|
|
193
|
+
carved_out := EXISTS (SELECT 1 FROM app.retention_carve_outs r WHERE r.table_name = v_tbl.name);
|
|
194
|
+
RETURN NEXT;
|
|
195
|
+
END LOOP;
|
|
196
|
+
END $$;
|
|
197
|
+
|
|
198
|
+
COMMENT ON FUNCTION public.tenant_export_manifest(uuid) IS
|
|
199
|
+
'Every tenant-scoped table holding rows for this tenant, with `carved_out` marking the ones a purge will leave behind (161). Discovered from the catalog, never from a list somebody maintains.';
|
|
200
|
+
|
|
201
|
+
REVOKE ALL ON FUNCTION public.tenant_export_manifest(uuid) FROM public, anon, authenticated;
|
|
202
|
+
GRANT EXECUTE ON FUNCTION public.tenant_export_manifest(uuid) TO service_role;
|
|
203
|
+
|
|
204
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
205
|
+
-- §3 The purge learns the exception
|
|
206
|
+
--
|
|
207
|
+
-- 155's sweep is re-emitted with one change: a carved-out table is skipped, and
|
|
208
|
+
-- the receipt SAYS which ones and why. A purge that silently kept rows would be
|
|
209
|
+
-- worse than one that kept none — the customer was told their data was deleted.
|
|
210
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
CREATE OR REPLACE FUNCTION public.tenant_purge_plan(p_tenant uuid)
|
|
213
|
+
RETURNS TABLE (table_name text, row_count bigint, action text, reason text)
|
|
214
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
215
|
+
AS $$
|
|
216
|
+
DECLARE
|
|
217
|
+
v_row record;
|
|
218
|
+
BEGIN
|
|
219
|
+
FOR v_row IN SELECT * FROM public.tenant_export_manifest(p_tenant)
|
|
220
|
+
LOOP
|
|
221
|
+
table_name := v_row.table_name;
|
|
222
|
+
row_count := v_row.row_count;
|
|
223
|
+
IF v_row.carved_out THEN
|
|
224
|
+
action := 'keep';
|
|
225
|
+
SELECT r.authority || ' — ' || r.reason INTO reason
|
|
226
|
+
FROM app.retention_carve_outs r WHERE r.table_name = v_row.table_name;
|
|
227
|
+
ELSE
|
|
228
|
+
action := 'delete';
|
|
229
|
+
reason := NULL;
|
|
230
|
+
END IF;
|
|
231
|
+
RETURN NEXT;
|
|
232
|
+
END LOOP;
|
|
233
|
+
END $$;
|
|
234
|
+
|
|
235
|
+
COMMENT ON FUNCTION public.tenant_purge_plan(uuid) IS
|
|
236
|
+
'What a purge would delete and what it would keep, with the rule behind each exception (161). Read before purging: an irreversible act should be previewable.';
|
|
237
|
+
|
|
238
|
+
REVOKE ALL ON FUNCTION public.tenant_purge_plan(uuid) FROM public, anon, authenticated;
|
|
239
|
+
GRANT EXECUTE ON FUNCTION public.tenant_purge_plan(uuid) TO service_role;
|
|
240
|
+
|
|
241
|
+
-- The settings a market can change without a migration, in one place, so
|
|
242
|
+
-- "N days, export on exit, fiscal carve-outs" is answerable by reading rather
|
|
243
|
+
-- than by grepping three functions.
|
|
244
|
+
CREATE OR REPLACE VIEW public.v_retention_policy
|
|
245
|
+
WITH (security_invoker = true) AS
|
|
246
|
+
SELECT t.id AS tenant_id,
|
|
247
|
+
coalesce((t.settings #>> '{retention,freeze_days}')::int, 90) AS freeze_days,
|
|
248
|
+
coalesce((t.settings #>> '{retention,export_on_exit}')::boolean, true) AS export_on_exit,
|
|
249
|
+
(SELECT count(*) FROM app.retention_carve_outs) AS carve_out_count,
|
|
250
|
+
(SELECT max(r.keep_years) FROM app.retention_carve_outs r) AS longest_carve_out_years
|
|
251
|
+
FROM public.tenants t;
|
|
252
|
+
|
|
253
|
+
COMMENT ON VIEW public.v_retention_policy IS
|
|
254
|
+
'The three retention settings per tenant (161, #284): how long a frozen account is kept, whether it is exported on the way out, and how many tables the law makes us keep regardless.';
|
|
255
|
+
|
|
256
|
+
GRANT SELECT ON public.v_retention_policy TO authenticated, service_role;
|
|
257
|
+
|
|
258
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
259
|
+
-- §4 The purge, re-emitted with the exception
|
|
260
|
+
--
|
|
261
|
+
-- 155's sweep, unchanged in shape — every table carrying `tenant_id`, repeated
|
|
262
|
+
-- until a pass moves nothing — plus one skip and one honest receipt. The whole
|
|
263
|
+
-- body is re-emitted rather than patched because a function has no partial
|
|
264
|
+
-- edit, and copying it is what makes the diff show exactly which two lines are
|
|
265
|
+
-- new.
|
|
266
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
CREATE OR REPLACE FUNCTION public.tenant_purge(p_tenant uuid, p_confirm text DEFAULT NULL)
|
|
269
|
+
RETURNS jsonb
|
|
270
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
271
|
+
AS $$
|
|
272
|
+
DECLARE
|
|
273
|
+
v_state text;
|
|
274
|
+
v_table record;
|
|
275
|
+
v_deleted bigint;
|
|
276
|
+
v_total bigint := 0;
|
|
277
|
+
v_receipt jsonb := '{}'::jsonb;
|
|
278
|
+
v_kept jsonb := '{}'::jsonb;
|
|
279
|
+
v_pass integer := 0;
|
|
280
|
+
v_moved boolean := true;
|
|
281
|
+
BEGIN
|
|
282
|
+
-- Two locks on the door, because this one has no undo.
|
|
283
|
+
IF p_confirm IS DISTINCT FROM p_tenant::text THEN
|
|
284
|
+
RAISE EXCEPTION 'tenant_purge: confirm must repeat the tenant id — this cannot be undone'
|
|
285
|
+
USING ERRCODE = '22023';
|
|
286
|
+
END IF;
|
|
287
|
+
|
|
288
|
+
SELECT state INTO v_state FROM app.tenant_lifecycle WHERE tenant_id = p_tenant;
|
|
289
|
+
IF v_state IS DISTINCT FROM 'frozen' THEN
|
|
290
|
+
RAISE EXCEPTION 'tenant_purge: % is not frozen (state %) — freeze first', p_tenant, coalesce(v_state, 'active')
|
|
291
|
+
USING ERRCODE = '23514',
|
|
292
|
+
HINT = 'The freeze is what gives a customer the window to change their mind.';
|
|
293
|
+
END IF;
|
|
294
|
+
|
|
295
|
+
-- What the law makes us keep, counted BEFORE anything is deleted, so the
|
|
296
|
+
-- receipt can say what survived and why. A purge that silently kept rows
|
|
297
|
+
-- would be worse than one that kept none: the customer was told otherwise.
|
|
298
|
+
FOR v_table IN
|
|
299
|
+
SELECT r.table_name, r.authority, r.reason FROM app.retention_carve_outs r
|
|
300
|
+
LOOP
|
|
301
|
+
IF to_regclass('public.' || quote_ident(v_table.table_name)) IS NULL THEN CONTINUE; END IF;
|
|
302
|
+
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id = $1', v_table.table_name)
|
|
303
|
+
INTO v_deleted USING p_tenant;
|
|
304
|
+
IF v_deleted > 0 THEN
|
|
305
|
+
v_kept := v_kept || jsonb_build_object(v_table.table_name,
|
|
306
|
+
jsonb_build_object('rows', v_deleted, 'authority', v_table.authority, 'reason', v_table.reason));
|
|
307
|
+
END IF;
|
|
308
|
+
END LOOP;
|
|
309
|
+
|
|
310
|
+
WHILE v_moved AND v_pass < 12 LOOP
|
|
311
|
+
v_moved := false;
|
|
312
|
+
v_pass := v_pass + 1;
|
|
313
|
+
FOR v_table IN
|
|
314
|
+
SELECT c.relname AS table_name, n.nspname AS schema_name
|
|
315
|
+
FROM pg_class c
|
|
316
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
317
|
+
JOIN information_schema.columns col
|
|
318
|
+
ON col.table_schema = n.nspname AND col.table_name = c.relname AND col.column_name = 'tenant_id'
|
|
319
|
+
WHERE c.relkind = 'r'
|
|
320
|
+
AND n.nspname IN ('public', 'app')
|
|
321
|
+
AND c.relname <> 'tenants'
|
|
322
|
+
AND c.relname <> 'tenant_lifecycle'
|
|
323
|
+
-- THE ONE NEW LINE. A fiscal obligation is not a preference, and an
|
|
324
|
+
-- erasure request cannot override a retention duty (LGPD art. 16, I).
|
|
325
|
+
AND NOT EXISTS (
|
|
326
|
+
SELECT 1 FROM app.retention_carve_outs r
|
|
327
|
+
WHERE r.table_name = c.relname AND n.nspname = 'public'
|
|
328
|
+
)
|
|
329
|
+
ORDER BY c.relname
|
|
330
|
+
LOOP
|
|
331
|
+
BEGIN
|
|
332
|
+
EXECUTE format('DELETE FROM %I.%I WHERE tenant_id = $1', v_table.schema_name, v_table.table_name)
|
|
333
|
+
USING p_tenant;
|
|
334
|
+
GET DIAGNOSTICS v_deleted = ROW_COUNT;
|
|
335
|
+
IF v_deleted > 0 THEN
|
|
336
|
+
v_moved := true;
|
|
337
|
+
v_total := v_total + v_deleted;
|
|
338
|
+
v_receipt := v_receipt || jsonb_build_object(
|
|
339
|
+
v_table.schema_name || '.' || v_table.table_name,
|
|
340
|
+
coalesce((v_receipt ->> (v_table.schema_name || '.' || v_table.table_name))::bigint, 0) + v_deleted);
|
|
341
|
+
END IF;
|
|
342
|
+
EXCEPTION WHEN foreign_key_violation THEN
|
|
343
|
+
-- Its parent is still there; the next pass will find it empty.
|
|
344
|
+
NULL;
|
|
345
|
+
END;
|
|
346
|
+
END LOOP;
|
|
347
|
+
END LOOP;
|
|
348
|
+
|
|
349
|
+
UPDATE app.tenant_lifecycle
|
|
350
|
+
SET state = 'purged', purged_at = now()
|
|
351
|
+
WHERE tenant_id = p_tenant;
|
|
352
|
+
|
|
353
|
+
RETURN jsonb_build_object('tenant_id', p_tenant, 'rows_deleted', v_total, 'passes', v_pass,
|
|
354
|
+
'receipt', v_receipt,
|
|
355
|
+
-- AND the second half of the truth.
|
|
356
|
+
'kept_by_law', v_kept);
|
|
357
|
+
END $$;
|
|
358
|
+
|
|
359
|
+
COMMENT ON FUNCTION public.tenant_purge(uuid, text) IS
|
|
360
|
+
'Deletes everything a frozen tenant owns EXCEPT what a retention rule requires kept, with a receipt for both halves (155 + 161). Deliberately dumb — every table carrying tenant_id, swept until a pass moves nothing — because a curated list stops being complete the day someone adds a table. `kept_by_law` names each exception and its authority: a purge that silently kept rows would be worse than one that kept none.';
|
|
361
|
+
|
|
362
|
+
REVOKE ALL ON FUNCTION public.tenant_purge(uuid, text) FROM public, anon, authenticated;
|
|
363
|
+
GRANT EXECUTE ON FUNCTION public.tenant_purge(uuid, text) TO service_role;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 162_every_name_has_one_creator_for_real.sql — the nine names the ownership
|
|
3
|
+
-- gate was still carrying as exemptions (#285, #282).
|
|
4
|
+
--
|
|
5
|
+
-- 148 stated the rule — one creator per table name — and shipped it with nine
|
|
6
|
+
-- declared exemptions, each one a real violation triaged and not fixed in the
|
|
7
|
+
-- same commit. A gate that starts red is a gate nobody turns on, so declaring
|
|
8
|
+
-- them was right. Leaving them declared forever would not be: an exemption that
|
|
9
|
+
-- outlives its author is a rule nobody enforces.
|
|
10
|
+
--
|
|
11
|
+
-- The nine are three different problems, and they get three different answers.
|
|
12
|
+
--
|
|
13
|
+
-- ── 1. Names the BASE took over (2) ────────────────────────────────────────
|
|
14
|
+
-- `public.documents` was created by plugin-forms and adopted by the base in 152
|
|
15
|
+
-- (#282, ADR 0020); `public.event_log` was created by plugin-automations and is
|
|
16
|
+
-- the base's since #277. Both plugins still carry a `CREATE TABLE IF NOT
|
|
17
|
+
-- EXISTS` for them, and that CREATE is now a guarded no-op — it exists so a
|
|
18
|
+
-- pool that ran the plugin BEFORE the base adopted the name still converges.
|
|
19
|
+
--
|
|
20
|
+
-- Deleting those lines is not available: a migration is never edited after it
|
|
21
|
+
-- has been applied, because the ledger treats checksum drift on an applied file
|
|
22
|
+
-- as a hard stop. So the base RECORDS the adoption, and the gate reads the
|
|
23
|
+
-- record — the same move 148 made for `app.base_owned_tables` and 158 for a
|
|
24
|
+
-- retired constraint. A guarded CREATE of an adopted name is history; an
|
|
25
|
+
-- unguarded one, or a CREATE of a name nobody adopted, still fails.
|
|
26
|
+
--
|
|
27
|
+
-- ── 2. Shop tables that predate the prefix rule (4) ────────────────────────
|
|
28
|
+
-- `payment_methods`, `fulfillments`, `fulfillment_items` and `order_events` are
|
|
29
|
+
-- the shop's own data wearing base-looking names. 141's mechanism applies
|
|
30
|
+
-- exactly: rename under the prefix, leave a view answering to the old name, and
|
|
31
|
+
-- no deploy has to be synchronised.
|
|
32
|
+
--
|
|
33
|
+
-- ── 3. Names 148 already moved (3) ─────────────────────────────────────────
|
|
34
|
+
-- `shipping_zones` and `shipping_settings` moved in 148 and `addresses` has
|
|
35
|
+
-- been base-owned since 017 — the shop's CREATEs for all three are the same
|
|
36
|
+
-- guarded no-op as case 1, and are recorded the same way.
|
|
37
|
+
-- ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
40
|
+
-- §1 The register: which names changed hands, and how
|
|
41
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS app.adopted_table_names (
|
|
44
|
+
table_name text PRIMARY KEY,
|
|
45
|
+
former_creator text NOT NULL,
|
|
46
|
+
new_owner text NOT NULL,
|
|
47
|
+
adopted_in text NOT NULL,
|
|
48
|
+
disposition text NOT NULL CHECK (disposition IN ('adopted', 'renamed')),
|
|
49
|
+
note text NOT NULL,
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
COMMENT ON TABLE app.adopted_table_names IS
|
|
54
|
+
'Table names whose creator changed (162): adopted by the base, or renamed under a plugin prefix. The review gate reads this so a plugin''s historical guarded CREATE can be told apart from a new violation — the same discipline as app.base_owned_tables (148) and app.retired_base_constraints (158).';
|
|
55
|
+
|
|
56
|
+
ALTER TABLE app.adopted_table_names ENABLE ROW LEVEL SECURITY;
|
|
57
|
+
DROP POLICY IF EXISTS adopted_table_names_read ON app.adopted_table_names;
|
|
58
|
+
CREATE POLICY adopted_table_names_read ON app.adopted_table_names
|
|
59
|
+
FOR SELECT TO authenticated, service_role USING (true);
|
|
60
|
+
GRANT SELECT ON app.adopted_table_names TO authenticated, service_role;
|
|
61
|
+
GRANT ALL ON app.adopted_table_names TO service_role;
|
|
62
|
+
|
|
63
|
+
INSERT INTO app.adopted_table_names (table_name, former_creator, new_owner, adopted_in, disposition, note) VALUES
|
|
64
|
+
('documents', 'forms', 'base', '152', 'adopted',
|
|
65
|
+
'The File archetype (#282, ADR 0020). plugin-forms created a base-looking name, so a pool without forms had no attachments at all. The base adopted the table and left the data alone — forms keeps a guarded CREATE for pools that ran it first.'),
|
|
66
|
+
('event_log', 'automations', 'base', '027/149', 'adopted',
|
|
67
|
+
'The domain-event log the base owns since #277. The plugin copy is a guarded no-op that converges a pool which ran automations before the base had the table.'),
|
|
68
|
+
('addresses', 'shop', 'base', '017', 'adopted',
|
|
69
|
+
'Base-owned since 017_core_addresses: an address is a fact about a business, not about a shop. The shop CREATE is a guarded duplicate.'),
|
|
70
|
+
('shipping_zones', 'shop', 'shop', '148', 'renamed',
|
|
71
|
+
'Moved to plg_shop_shipping_zones by 148 with a compat view. The old CREATE is history.'),
|
|
72
|
+
('shipping_settings', 'shop', 'shop', '148', 'renamed',
|
|
73
|
+
'Moved to plg_shop_shipping_settings by 148 with a compat view. The old CREATE is history.'),
|
|
74
|
+
('payment_methods', 'shop', 'shop', '162', 'renamed',
|
|
75
|
+
'Which cards a store accepts is a fact about running that shop. Renamed under the prefix here, with a compat view.'),
|
|
76
|
+
('fulfillments', 'shop', 'shop', '162', 'renamed',
|
|
77
|
+
'A fulfilment is the shop''s own record of a dispatch. Renamed under the prefix here.'),
|
|
78
|
+
('fulfillment_items', 'shop', 'shop', '162', 'renamed',
|
|
79
|
+
'Its lines. Renamed with it.'),
|
|
80
|
+
('order_events', 'shop', 'shop', '162', 'renamed',
|
|
81
|
+
'The shop''s per-order timeline — NOT the domain-event log, despite the name, which is precisely why it should not have worn one that reads like the base''s.')
|
|
82
|
+
ON CONFLICT (table_name) DO UPDATE
|
|
83
|
+
SET former_creator = EXCLUDED.former_creator, new_owner = EXCLUDED.new_owner,
|
|
84
|
+
adopted_in = EXCLUDED.adopted_in, disposition = EXCLUDED.disposition, note = EXCLUDED.note;
|
|
85
|
+
|
|
86
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
87
|
+
-- §2 The four that still need moving live in the SHOP's chain
|
|
88
|
+
--
|
|
89
|
+
-- They were here first, and that was wrong: the base chain runs before any
|
|
90
|
+
-- plugin's, so at this point `public.payment_methods` does not exist yet and
|
|
91
|
+
-- the rename found nothing to move. A migration that silently does nothing is
|
|
92
|
+
-- worse than one that fails — it leaves the register above claiming a rename
|
|
93
|
+
-- that never happened.
|
|
94
|
+
--
|
|
95
|
+
-- So the rename is `packages/shop/migrations/0087`, which runs after the tables
|
|
96
|
+
-- it renames. What stays here is the DECLARATION, which is the base's business
|
|
97
|
+
-- and is what the review gate reads.
|
|
98
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
101
|
+
-- §3 The census
|
|
102
|
+
--
|
|
103
|
+
-- An unprefixed name in `public` that neither the base claims (148) nor the
|
|
104
|
+
-- adoption register explains (this file) is a new violation. Zero rows is the
|
|
105
|
+
-- expected reading, and the reading is available from the database rather than
|
|
106
|
+
-- only from CI — a pool carries tables from versions the repository has moved
|
|
107
|
+
-- past.
|
|
108
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
CREATE OR REPLACE VIEW public.v_unclaimed_table_names
|
|
111
|
+
WITH (security_invoker = true) AS
|
|
112
|
+
SELECT c.relname::text AS table_name
|
|
113
|
+
FROM pg_class c
|
|
114
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
115
|
+
WHERE n.nspname = 'public'
|
|
116
|
+
AND c.relkind = 'r'
|
|
117
|
+
AND c.relname NOT LIKE 'plg\_%'
|
|
118
|
+
AND c.relname NOT LIKE '\_%'
|
|
119
|
+
AND NOT EXISTS (SELECT 1 FROM app.base_owned_tables b WHERE b.table_name = c.relname)
|
|
120
|
+
AND NOT EXISTS (SELECT 1 FROM app.adopted_table_names a WHERE a.table_name = c.relname)
|
|
121
|
+
ORDER BY c.relname;
|
|
122
|
+
|
|
123
|
+
COMMENT ON VIEW public.v_unclaimed_table_names IS
|
|
124
|
+
'Unprefixed tables in public that neither the base claims (148) nor the adoption register explains (162). A row here is a name with no declared creator — the thing "one creator per table name" exists to prevent.';
|
|
125
|
+
|
|
126
|
+
GRANT SELECT ON public.v_unclaimed_table_names TO authenticated, service_role;
|