@fayz-ai/db 0.13.0 → 0.14.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.
Files changed (187) hide show
  1. package/canonical-schema.json +106 -23
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +1 -1
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -46
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -90
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -1,132 +0,0 @@
1
- -- ============================================================================
2
- -- 103_migration_reconcile_ponr.sql — the reconciliation report (PRD 10 R6)
3
- -- and the point-of-no-return stamp (ADR 0008).
4
- --
5
- -- reconcile(batch): counts per source table, a checksum over the tenant's
6
- -- ledger rows (stable across a replay), FK sanity (migrated target ids that no
7
- -- longer exist), financial sums Σ(ledger) vs Σ(destination) per (table, column)
8
- -- to the cent unless an approved exception covers the key, open quarantines and
9
- -- pending rows. green ⇔ nothing open, nothing missing, every sum matches (or is
10
- -- excepted), nothing pending. Stored in migration.reconciliation_reports.
11
- --
12
- -- PONR: the first write to a fenced tenant's business data by anyone who is
13
- -- NOT the migration stamps tenant_fence.first_v2_write_at; from then on the
14
- -- kill switch refuses. Canonical stamp: AFTER row triggers on public.orders and
15
- -- public.people (cheap: one GUC read, one PK lookup) — the two tables every
16
- -- V2 session writes first. migration.mark_v2_write(tenant) is the explicit
17
- -- form for a service that knows it wrote as V2.
18
- -- ============================================================================
19
-
20
- CREATE OR REPLACE FUNCTION migration.reconcile(p_batch uuid) RETURNS jsonb
21
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
22
- DECLARE
23
- b migration.batches%ROWTYPE;
24
- v_batch_counts jsonb; v_tenant_counts jsonb; v_checksum text;
25
- v_fk jsonb := '[]'::jsonb; v_fk_missing integer := 0; v_fk_checked integer := 0;
26
- v_values jsonb := '[]'::jsonb; v_value_mismatch integer := 0;
27
- v_quar jsonb; v_quar_open integer; v_exceptions jsonb; v_pending integer;
28
- v_green boolean; v_report jsonb; v_id uuid;
29
- r record; v_missing integer; v_sample jsonb; v_dest numeric; v_led numeric; v_exc boolean; v_match boolean;
30
- BEGIN
31
- SELECT * INTO b FROM migration.batches WHERE id = p_batch;
32
- IF NOT FOUND THEN RAISE EXCEPTION 'reconcile: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
33
-
34
- SELECT coalesce(jsonb_object_agg(source_table, c), '{}'::jsonb) INTO v_batch_counts
35
- FROM (SELECT source_table, jsonb_object_agg(status, n) AS c
36
- FROM (SELECT source_table, status, count(*) AS n FROM migration.ledger WHERE batch_id = p_batch GROUP BY 1, 2) s
37
- GROUP BY source_table) t;
38
- SELECT coalesce(jsonb_object_agg(source_table, c), '{}'::jsonb) INTO v_tenant_counts
39
- FROM (SELECT source_table, jsonb_object_agg(status, n) AS c
40
- FROM (SELECT source_table, status, count(*) AS n FROM migration.ledger
41
- WHERE tenant_id = b.tenant_id AND source_project = b.source_project GROUP BY 1, 2) s
42
- GROUP BY source_table) t;
43
- SELECT md5(coalesce(string_agg(
44
- concat_ws('|', source_table, source_numeric_id, source_uuid, target_table, target_id, status, checksum, cursor), E'\n'
45
- ORDER BY source_table, source_numeric_id, source_uuid, target_table), ''))
46
- INTO v_checksum
47
- FROM migration.ledger WHERE tenant_id = b.tenant_id AND source_project = b.source_project;
48
-
49
- FOR r IN SELECT target_table, count(*) AS n FROM migration.ledger
50
- WHERE tenant_id = b.tenant_id AND source_project = b.source_project AND status = 'migrated'
51
- AND target_table IS NOT NULL AND target_id IS NOT NULL
52
- GROUP BY 1 ORDER BY 1 LOOP
53
- v_fk_checked := v_fk_checked + r.n;
54
- IF to_regclass(r.target_table) IS NULL THEN
55
- v_fk := v_fk || jsonb_build_object('target_table', r.target_table, 'checked', r.n, 'missing', r.n, 'note', 'destination table absent');
56
- v_fk_missing := v_fk_missing + r.n;
57
- CONTINUE;
58
- END IF;
59
- EXECUTE format('SELECT count(*) FROM (SELECT DISTINCT l.target_id FROM migration.ledger l WHERE l.tenant_id = $1 AND l.source_project = $2 AND l.status = ''migrated'' AND l.target_table = $3) l LEFT JOIN %s d ON d.id = l.target_id WHERE d.id IS NULL', r.target_table)
60
- INTO v_missing USING b.tenant_id, b.source_project, r.target_table;
61
- EXECUTE format('SELECT coalesce(jsonb_agg(x), ''[]''::jsonb) FROM (SELECT l.target_id AS x FROM (SELECT DISTINCT l.target_id FROM migration.ledger l WHERE l.tenant_id = $1 AND l.source_project = $2 AND l.status = ''migrated'' AND l.target_table = $3) l LEFT JOIN %s d ON d.id = l.target_id WHERE d.id IS NULL LIMIT 20) s', r.target_table)
62
- INTO v_sample USING b.tenant_id, b.source_project, r.target_table;
63
- v_fk := v_fk || jsonb_build_object('target_table', r.target_table, 'checked', r.n, 'missing', v_missing, 'sample', v_sample);
64
- v_fk_missing := v_fk_missing + v_missing;
65
- END LOOP;
66
-
67
- FOR r IN SELECT l.target_table, k AS col FROM migration.ledger l, LATERAL jsonb_object_keys(l.financial) k
68
- WHERE l.tenant_id = b.tenant_id AND l.source_project = b.source_project AND l.status = 'migrated' AND l.financial IS NOT NULL
69
- GROUP BY 1, 2 ORDER BY 1, 2 LOOP
70
- SELECT coalesce(sum((financial ->> r.col)::numeric), 0) INTO v_led FROM migration.ledger
71
- WHERE tenant_id = b.tenant_id AND source_project = b.source_project AND status = 'migrated' AND target_table = r.target_table AND financial ? r.col;
72
- IF to_regclass(r.target_table) IS NULL OR NOT migration._has_column(r.target_table, r.col) THEN
73
- v_dest := NULL;
74
- ELSE
75
- EXECUTE format('SELECT coalesce(sum(d.%I), 0) FROM %s d WHERE d.id IN (SELECT DISTINCT l.target_id FROM migration.ledger l WHERE l.tenant_id = $1 AND l.source_project = $2 AND l.status = ''migrated'' AND l.target_table = $3 AND l.financial ? $4)', r.col, r.target_table)
76
- INTO v_dest USING b.tenant_id, b.source_project, r.target_table, r.col;
77
- END IF;
78
- v_exc := EXISTS (SELECT 1 FROM migration.exceptions e WHERE e.tenant_id = b.tenant_id AND e.key = r.target_table || '.' || r.col AND (e.batch_id IS NULL OR e.batch_id = p_batch));
79
- v_match := v_dest IS NOT NULL AND round(v_led, 2) = round(v_dest, 2);
80
- IF NOT v_match AND NOT v_exc THEN v_value_mismatch := v_value_mismatch + 1; END IF;
81
- v_values := v_values || jsonb_build_object('target_table', r.target_table, 'column', r.col, 'ledger_sum', round(v_led, 2),
82
- 'destination_sum', round(v_dest, 2), 'match', v_match, 'exception', v_exc);
83
- END LOOP;
84
-
85
- SELECT coalesce(sum(n), 0)::integer, coalesce(jsonb_object_agg(kind, n), '{}'::jsonb) INTO v_quar_open, v_quar
86
- FROM (SELECT q.kind, count(*) AS n FROM migration.quarantine q JOIN migration.ledger l ON l.id = q.ledger_id
87
- WHERE l.tenant_id = b.tenant_id AND l.source_project = b.source_project AND q.resolved_at IS NULL GROUP BY q.kind) s;
88
- SELECT count(*)::integer INTO v_pending FROM migration.ledger
89
- WHERE tenant_id = b.tenant_id AND source_project = b.source_project AND status = 'pending';
90
- SELECT coalesce(jsonb_agg(jsonb_build_object('id', e.id, 'source_table', e.source_table, 'key', e.key, 'reason', e.reason, 'approved_by', e.approved_by, 'approved_at', e.approved_at) ORDER BY e.id), '[]'::jsonb)
91
- INTO v_exceptions FROM migration.exceptions e WHERE e.tenant_id = b.tenant_id AND (e.batch_id IS NULL OR e.batch_id = p_batch);
92
-
93
- v_green := v_quar_open = 0 AND v_fk_missing = 0 AND v_value_mismatch = 0 AND v_pending = 0;
94
- v_report := jsonb_build_object(
95
- 'batch', jsonb_build_object('id', b.id, 'status', b.status, 'source_project', b.source_project, 'source_tenant_id', b.source_tenant_id,
96
- 'cursor_from', b.cursor_from, 'cursor_to', b.cursor_to, 'outcomes', b.counts, 'rows', v_batch_counts),
97
- 'tenant', jsonb_build_object('tenant_id', b.tenant_id, 'source_project', b.source_project, 'counts', v_tenant_counts, 'checksum', v_checksum,
98
- 'fk', jsonb_build_object('checked', v_fk_checked, 'missing', v_fk_missing, 'by_table', v_fk),
99
- 'values', v_values, 'quarantine', jsonb_build_object('open', v_quar_open, 'by_kind', v_quar),
100
- 'pending', v_pending, 'exceptions', v_exceptions),
101
- 'green', v_green, 'generated_at', now());
102
- INSERT INTO migration.reconciliation_reports (tenant_id, batch_id, green, report) VALUES (b.tenant_id, p_batch, v_green, v_report) RETURNING id INTO v_id;
103
- RETURN v_report || jsonb_build_object('report_id', v_id);
104
- END $$;
105
- REVOKE ALL ON FUNCTION migration.reconcile(uuid) FROM public, anon, authenticated;
106
- GRANT EXECUTE ON FUNCTION migration.reconcile(uuid) TO service_role;
107
-
108
- -- ─────────────────────────────── PONR stamp ────────────────────────────
109
- CREATE OR REPLACE FUNCTION migration.trg_ponr_stamp() RETURNS trigger
110
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
111
- DECLARE v_tenant uuid;
112
- BEGIN
113
- IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN RETURN NULL; END IF;
114
- v_tenant := CASE WHEN TG_OP = 'DELETE' THEN OLD.tenant_id ELSE NEW.tenant_id END;
115
- IF v_tenant IS NOT NULL THEN
116
- UPDATE migration.tenant_fence SET first_v2_write_at = now()
117
- WHERE tenant_id = v_tenant AND first_v2_write_at IS NULL AND state IN ('fenced', 'v2_writer');
118
- END IF;
119
- RETURN NULL;
120
- END $$;
121
- REVOKE ALL ON FUNCTION migration.trg_ponr_stamp() FROM public, anon, authenticated;
122
-
123
- DO $$
124
- DECLARE t text;
125
- BEGIN
126
- FOREACH t IN ARRAY ARRAY['orders', 'people'] LOOP
127
- IF to_regclass('public.' || t) IS NOT NULL THEN
128
- EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_migration_ponr', t);
129
- EXECUTE format('CREATE TRIGGER %I AFTER INSERT OR UPDATE OR DELETE ON public.%I FOR EACH ROW EXECUTE FUNCTION migration.trg_ponr_stamp()', t || '_migration_ponr', t);
130
- END IF;
131
- END LOOP;
132
- END $$;
@@ -1,71 +0,0 @@
1
- -- ============================================================================
2
- -- 105_policies_to_authenticated.sql — no policy without `TO authenticated` (#147).
3
- --
4
- -- The 001-era policies were written FOR PUBLIC (no role clause): audit_logs,
5
- -- invitations, invoices, notifications, profiles, subscriptions, tenants,
6
- -- tenant_members/locations (compat views since #134), tenant_role*. A policy
7
- -- applying to PUBLIC is evaluated for anon too, and every new table born with
8
- -- a PUBLIC policy widens the anonymous surface by accident. The rule of the
9
- -- authz spec (§7, non-negotiable) is `to authenticated` on every policy; the
10
- -- verification suite (900_verification/001_posture) grades it, so this file
11
- -- re-creates each remaining PUBLIC policy with the same predicate, scoped to
12
- -- authenticated. Dynamic and idempotent: a policy that is already role-scoped
13
- -- is untouched, and re-running finds nothing to do.
14
- --
15
- -- Anon never loses anything it should keep: the core chain has no anonymous
16
- -- read surface on these tables (the storefront/booking anon paths live in the
17
- -- shop/agenda plugins and are named `_anon_*` policies with an explicit role).
18
- -- ============================================================================
19
- DO $$
20
- DECLARE r record; v_cmd text;
21
- BEGIN
22
- FOR r IN
23
- SELECT schemaname, tablename, policyname, permissive, cmd, qual, with_check
24
- FROM pg_policies
25
- WHERE roles = '{public}'
26
- AND (schemaname = 'app'
27
- -- the generic isolation policies 002 stamps on every public table with a
28
- -- tenant_id (including plugin tables it discovers on a re-run): same
29
- -- predicate, so scoping them to authenticated changes nothing for a
30
- -- signed-in user and closes the anonymous evaluation
31
- OR policyname LIKE 'tenant\_isolation\_%'
32
- -- the spine's own 001-era tables only: a plugin (shop, agenda…) that
33
- -- deliberately opened a PUBLIC read policy for its anonymous surface is
34
- -- its own decision, and this file must converge the same way whether it
35
- -- runs before or after that plugin's migrations
36
- OR (schemaname = 'public' AND tablename IN (
37
- 'tenants', 'profiles', 'tenant_members', 'plans', 'permissions', 'role_permissions',
38
- 'tenant_role_overrides', 'tenant_roles', 'invitations', 'payment_events', 'locations',
39
- 'location_members', 'audit_logs', 'subscriptions', 'invoices', 'notifications',
40
- 'fayz_projects', 'addresses')))
41
- LOOP
42
- v_cmd := format('CREATE POLICY %I ON %I.%I AS %s FOR %s TO authenticated',
43
- r.policyname, r.schemaname, r.tablename,
44
- CASE WHEN r.permissive = 'PERMISSIVE' THEN 'PERMISSIVE' ELSE 'RESTRICTIVE' END,
45
- r.cmd);
46
- IF r.qual IS NOT NULL THEN v_cmd := v_cmd || format(' USING (%s)', r.qual); END IF;
47
- IF r.with_check IS NOT NULL THEN v_cmd := v_cmd || format(' WITH CHECK (%s)', r.with_check); END IF;
48
- EXECUTE format('DROP POLICY IF EXISTS %I ON %I.%I', r.policyname, r.schemaname, r.tablename);
49
- EXECUTE v_cmd;
50
- END LOOP;
51
- END $$;
52
-
53
- -- ── anon executes nothing in schema app ─────────────────────────────────
54
- -- Functions in `app` are born with the built-in default (EXECUTE to PUBLIC);
55
- -- 050's per-schema default-privilege REVOKE cannot subtract it, so every helper
56
- -- has to revoke explicitly. One slipped (a hashing helper). Sweep the schema so
57
- -- the rule holds regardless of who forgets: nothing callable in `app` for anon
58
- -- or PUBLIC; the intended callers (authenticated/service_role) keep what they
59
- -- were granted explicitly. Trigger functions cannot be called directly anyway.
60
- DO $$
61
- DECLARE r record;
62
- BEGIN
63
- FOR r IN
64
- SELECT p.oid::regprocedure AS sig
65
- FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
66
- WHERE n.nspname = 'app'
67
- AND has_function_privilege('anon', p.oid, 'EXECUTE')
68
- LOOP
69
- EXECUTE format('REVOKE ALL ON FUNCTION %s FROM public, anon', r.sig);
70
- END LOOP;
71
- END $$;
@@ -1,55 +0,0 @@
1
- -- ============================================================================
2
- -- 106_function_search_path.sql — every function in public/app/migration gets a
3
- -- fixed search_path.
4
- --
5
- -- Found by applying the canonical chain to a real Supabase project (the
6
- -- rehearsal, 2026-08-19): the platform's own linter flags 25 functions whose
7
- -- search_path is resolved from the caller's session. For a SECURITY DEFINER
8
- -- function that is a privilege-escalation vector — a caller who can create an
9
- -- object in a schema earlier on the path decides what an unqualified name in
10
- -- the body means. `public.handle_new_user` (spine 001, runs on every signup)
11
- -- was the one definer in that state; the rest are trigger and helper functions
12
- -- where it is a lint rather than a hole, but the rule is cheap to hold.
13
- --
14
- -- Done as ALTER, not CREATE OR REPLACE: the bodies are not touched, so this
15
- -- cannot change behaviour beyond making name resolution deterministic. The
16
- -- path pinned per schema is the one those bodies already resolve through
17
- -- (their own schema first, then public and extensions), so unqualified
18
- -- references keep meaning what they mean today. Functions that already declare
19
- -- a search_path — every RPC this phase adds declares `SET search_path = ''` —
20
- -- are left alone.
21
- --
22
- -- Idempotent: re-running finds nothing without a search_path and does nothing.
23
- -- ============================================================================
24
- DO $$
25
- DECLARE
26
- r record;
27
- v_path text;
28
- BEGIN
29
- FOR r IN
30
- SELECT p.oid, n.nspname, p.oid::regprocedure AS sig, p.prosecdef
31
- FROM pg_proc p
32
- JOIN pg_namespace n ON n.oid = p.pronamespace
33
- WHERE n.nspname IN ('public', 'app', 'migration')
34
- AND p.prokind = 'f'
35
- AND NOT EXISTS (
36
- SELECT 1 FROM unnest(coalesce(p.proconfig, '{}')) c WHERE c LIKE 'search_path=%')
37
- -- NOT OURS TO PIN. An extension installed into public brings its own
38
- -- functions with it — school has btree_gist, so gbt_date_penalty and
39
- -- friends sit in public owned by a superuser, and the sweep stops on
40
- -- "must be owner of function". Even with the rights it would be the wrong
41
- -- move: those bodies belong to the extension, its next upgrade rewrites
42
- -- them, and the lint they trip is the extension author's to answer.
43
- AND NOT EXISTS (
44
- SELECT 1 FROM pg_depend d
45
- WHERE d.objid = p.oid AND d.classid = 'pg_proc'::regclass AND d.deptype = 'e')
46
- -- and never a function this role cannot alter, whatever brought it here.
47
- AND pg_catalog.pg_has_role(p.proowner, 'USAGE')
48
- LOOP
49
- v_path := CASE r.nspname
50
- WHEN 'public' THEN 'public, extensions, pg_temp'
51
- ELSE format('%I, public, extensions, pg_temp', r.nspname)
52
- END;
53
- EXECUTE format('ALTER FUNCTION %s SET search_path = %s', r.sig, v_path);
54
- END LOOP;
55
- END $$;
@@ -1,361 +0,0 @@
1
- -- ============================================================================
2
- -- 107_core_table_prefix.sql — eight core tables drop the `plg_` prefix.
3
- --
4
- -- `plg_` means "owned by plugin <name>": docs/plugins/PLUGIN-CONVENTION.md §3
5
- -- reads every table a plugin owns as `plg_<plugin>_*`, and
6
- -- scripts/emit-plg-census.mjs is the ledger of that rule. Eight tables of the
7
- -- CORE SPINE wear the prefix with no plugin segment at all, so the name claims
8
- -- an owner that does not exist and the census has to special-case them:
9
- --
10
- -- plg_analytics_read_models → analytics_read_models (024)
11
- -- plg_connections → connections (029)
12
- -- plg_connection_secrets → connection_secrets (032)
13
- -- plg_connector_schedules → connector_schedules (033)
14
- -- plg_entity_fields → entity_fields (035)
15
- -- plg_event_log → event_log (027)
16
- -- plg_sync_runs → sync_runs (029)
17
- -- plg_sync_schedule → sync_schedule (033)
18
- --
19
- -- WHY THIS FILE AND NOT AN EDIT OF 024/027/029/031/032/033/035. Those files are
20
- -- applied in production pools; an applied migration is never edited (the ledger
21
- -- turns an edit into a hard stop). The rename is therefore additive: the frozen
22
- -- files keep creating the old names on a pool that has never seen them, and
23
- -- this file carries them over.
24
- --
25
- -- WHY NO COMPAT VIEWS UNDER THE OLD NAMES. The tenancy retirement
26
- -- (063_legacy_tenancy_retired.sql) proved that shape impossible here: on the
27
- -- bench's force-replay pass the frozen files re-run and execute
28
- -- `ALTER TABLE public.plg_x ENABLE ROW LEVEL SECURITY`, `ADD COLUMN IF NOT
29
- -- EXISTS`, `CREATE POLICY … ON public.plg_x` — every one of which errors with
30
- -- "this operation is not supported for views". 053 answered that with
31
- -- projection TABLES because the legacy names had to keep taking writes. Here
32
- -- they must not: the whole point is that the old name stops existing.
33
- --
34
- -- WHAT A REPLAY LOOKS LIKE. On a re-apply the frozen files run again and
35
- -- `CREATE TABLE IF NOT EXISTS public.plg_x` re-creates the old name EMPTY,
36
- -- beside the renamed one that holds the data. So each table has exactly three
37
- -- states and this file handles all three:
38
- --
39
- -- 1. old exists, new does not → ALTER TABLE … RENAME TO. The data, indexes,
40
- -- constraints, policies, grants and dependent FKs all travel with it.
41
- -- 2. both exist → the old one can only be that empty
42
- -- re-creation. DROP it when it has zero rows; RAISE EXCEPTION when it has
43
- -- any, because a pool with a live writer on the old name must fail loudly
44
- -- rather than lose rows.
45
- -- 3. neither/only the new one → nothing to do. Re-running is a no-op.
46
- --
47
- -- plg_event_log is ALSO declared by plugins/plugin-automations 0002_event_log
48
- -- ("core table also CREATEd by this plugin" in the census). That file is
49
- -- applied too and is not edited: its `CREATE TABLE IF NOT EXISTS` is a no-op on
50
- -- a pool that has 027, and where it does re-create the empty duplicate this
51
- -- file drops it by branch 2. THE CORE OWNS event_log — 027 is the definition,
52
- -- automations is a legacy first writer.
53
- --
54
- -- SCOPE: TABLES ONLY. The core functions that carry the same prefix
55
- -- (plg_emit_event, plg_claim_effect, plg_settle_effect, plg_prune_sync_runs,
56
- -- plg_store_connection_secret, plg_read_connection_secret,
57
- -- plg_drop_connection_secrets, plg_register_connector_schedule,
58
- -- plg_set_scheduler_endpoint, plg_sync_tick, plg_emit_row_event,
59
- -- plg_attach_event_source) are called by name from TypeScript and from edge
60
- -- functions that are already DEPLOYED, so renaming them is a separate wave —
61
- -- see docs/data/phase-0/12-core-table-prefix.md. Their BODIES are repointed
62
- -- here (§3), because a body still naming plg_connections is a runtime error the
63
- -- moment the table is gone.
64
- -- ============================================================================
65
-
66
- -- ── §1 the tables ──────────────────────────────────────────────────────────
67
- DO $$
68
- DECLARE
69
- -- Children before parents, so a duplicate is dropped before the duplicate it
70
- -- references (plg_connection_secrets / plg_sync_runs / plg_sync_schedule all
71
- -- FK plg_connections).
72
- v_pairs text[][] := ARRAY[
73
- ['plg_connection_secrets', 'connection_secrets'],
74
- ['plg_sync_runs', 'sync_runs'],
75
- ['plg_sync_schedule', 'sync_schedule'],
76
- ['plg_connections', 'connections'],
77
- ['plg_connector_schedules', 'connector_schedules'],
78
- ['plg_analytics_read_models', 'analytics_read_models'],
79
- ['plg_entity_fields', 'entity_fields'],
80
- ['plg_event_log', 'event_log']
81
- ];
82
- i integer;
83
- v_old text;
84
- v_new text;
85
- v_rows bigint;
86
- BEGIN
87
- -- Pass A — branch 2: the empty re-creation left by a replay of the frozen file.
88
- FOR i IN 1 .. array_length(v_pairs, 1) LOOP
89
- v_old := v_pairs[i][1];
90
- v_new := v_pairs[i][2];
91
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_old)) IS NULL;
92
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_new)) IS NULL;
93
-
94
- EXECUTE format('SELECT count(*) FROM public.%I', v_old) INTO v_rows;
95
- IF v_rows > 0 THEN
96
- RAISE EXCEPTION
97
- 'public.% still holds % row(s) while public.% exists: something is still WRITING the old name. Point that writer at public.% and move the rows across before re-applying 097 — this migration will not drop data.',
98
- v_old, v_rows, v_new, v_new
99
- USING ERRCODE = 'raise_exception';
100
- END IF;
101
-
102
- EXECUTE format('DROP TABLE public.%I', v_old);
103
- RAISE NOTICE '097: dropped the empty re-creation public.% (public.% is the table)', v_old, v_new;
104
- END LOOP;
105
-
106
- -- Pass B — branch 1: the rename that carries everything.
107
- FOR i IN 1 .. array_length(v_pairs, 1) LOOP
108
- v_old := v_pairs[i][1];
109
- v_new := v_pairs[i][2];
110
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_old)) IS NULL;
111
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_new)) IS NOT NULL;
112
-
113
- EXECUTE format('ALTER TABLE public.%I RENAME TO %I', v_old, v_new);
114
- RAISE NOTICE '097: renamed public.% → public.%', v_old, v_new;
115
- END LOOP;
116
- END $$;
117
-
118
- -- ── §2 the names bolted to those tables ────────────────────────────────────
119
- -- A rename leaves every index, constraint, trigger and policy under its old
120
- -- name, so `\d connections` would still read plg_. Each is renamed by dropping the
121
- -- `plg_` segment, and only when the target name is free — on a replay the
122
- -- frozen file has re-created the old names on the (already dropped) duplicate,
123
- -- so this loop simply finds nothing left to do.
124
- DO $$
125
- DECLARE
126
- v_tables text[] := ARRAY[
127
- 'analytics_read_models', 'connection_secrets', 'connections', 'connector_schedules',
128
- 'entity_fields', 'event_log', 'sync_runs', 'sync_schedule'
129
- ];
130
- r record;
131
- v_new text;
132
- BEGIN
133
- -- Constraints first: renaming a PRIMARY KEY / UNIQUE constraint renames the
134
- -- index behind it, so the index loop below then has nothing to say about it.
135
- FOR r IN
136
- SELECT c.conname, t.relname AS tbl
137
- FROM pg_constraint c
138
- JOIN pg_class t ON t.oid = c.conrelid
139
- JOIN pg_namespace n ON n.oid = t.relnamespace
140
- WHERE n.nspname = 'public'
141
- AND t.relname = ANY (v_tables)
142
- AND c.conname LIKE '%plg\_%'
143
- ORDER BY t.relname, c.conname
144
- LOOP
145
- v_new := replace(r.conname, 'plg_', '');
146
- CONTINUE WHEN v_new = r.conname OR v_new = '';
147
- CONTINUE WHEN EXISTS (
148
- SELECT 1 FROM pg_constraint c2
149
- JOIN pg_class t2 ON t2.oid = c2.conrelid
150
- WHERE t2.relname = r.tbl AND c2.conname = v_new);
151
- EXECUTE format('ALTER TABLE public.%I RENAME CONSTRAINT %I TO %I', r.tbl, r.conname, v_new);
152
- END LOOP;
153
-
154
- FOR r IN
155
- SELECT ic.relname AS idx, t.relname AS tbl
156
- FROM pg_index i
157
- JOIN pg_class ic ON ic.oid = i.indexrelid
158
- JOIN pg_class t ON t.oid = i.indrelid
159
- JOIN pg_namespace n ON n.oid = t.relnamespace
160
- WHERE n.nspname = 'public'
161
- AND t.relname = ANY (v_tables)
162
- AND ic.relname LIKE '%plg\_%'
163
- ORDER BY t.relname, ic.relname
164
- LOOP
165
- v_new := replace(r.idx, 'plg_', '');
166
- CONTINUE WHEN v_new = r.idx OR v_new = '';
167
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_new)) IS NOT NULL;
168
- EXECUTE format('ALTER INDEX public.%I RENAME TO %I', r.idx, v_new);
169
- END LOOP;
170
-
171
- FOR r IN
172
- SELECT g.tgname AS trg, t.relname AS tbl
173
- FROM pg_trigger g
174
- JOIN pg_class t ON t.oid = g.tgrelid
175
- JOIN pg_namespace n ON n.oid = t.relnamespace
176
- WHERE n.nspname = 'public'
177
- AND NOT g.tgisinternal
178
- AND t.relname = ANY (v_tables)
179
- AND g.tgname LIKE '%plg\_%'
180
- ORDER BY t.relname, g.tgname
181
- LOOP
182
- v_new := replace(r.trg, 'plg_', '');
183
- CONTINUE WHEN v_new = r.trg OR v_new = '';
184
- CONTINUE WHEN EXISTS (
185
- SELECT 1 FROM pg_trigger g2
186
- JOIN pg_class t2 ON t2.oid = g2.tgrelid
187
- WHERE t2.relname = r.tbl AND g2.tgname = v_new);
188
- EXECUTE format('ALTER TRIGGER %I ON public.%I RENAME TO %I', r.trg, r.tbl, v_new);
189
- END LOOP;
190
-
191
- FOR r IN
192
- SELECT p.policyname AS pol, p.tablename AS tbl
193
- FROM pg_policies p
194
- WHERE p.schemaname = 'public'
195
- AND p.tablename = ANY (v_tables)
196
- AND p.policyname LIKE '%plg\_%'
197
- ORDER BY p.tablename, p.policyname
198
- LOOP
199
- v_new := replace(r.pol, 'plg_', '');
200
- CONTINUE WHEN v_new = r.pol OR v_new = '';
201
- CONTINUE WHEN EXISTS (
202
- SELECT 1 FROM pg_policies p2
203
- WHERE p2.schemaname = 'public' AND p2.tablename = r.tbl AND p2.policyname = v_new);
204
- EXECUTE format('ALTER POLICY %I ON public.%I RENAME TO %I', r.pol, r.tbl, v_new);
205
- END LOOP;
206
- END $$;
207
-
208
- -- ── §3 every function body that still names an old table ───────────────────
209
- -- Done the way 096 pinned search_path: read what the database has, rewrite it,
210
- -- put it back — rather than restating a dozen frozen bodies here and letting
211
- -- the copy rot. `pg_get_functiondef` returns CREATE OR REPLACE, so the OID,
212
- -- owner, ACL and every dependency on the function survive; only the eight table
213
- -- names change, and only where they appear as whole words (`\m…\M`), so
214
- -- plg_prune_sync_runs, uq_plg_connections_account_key and
215
- -- plg_connections_member_read are untouched.
216
- --
217
- -- Extension-owned functions are skipped, and a function whose text does not
218
- -- change is not re-created — so a replay finds nothing to do.
219
- DO $$
220
- DECLARE
221
- v_names text[] := ARRAY[
222
- 'analytics_read_models', 'connection_secrets', 'connections', 'connector_schedules',
223
- 'entity_fields', 'event_log', 'sync_runs', 'sync_schedule'
224
- ];
225
- r record;
226
- v_def text;
227
- v_name text;
228
- i integer;
229
- v_count integer := 0;
230
- BEGIN
231
- FOR r IN
232
- SELECT p.oid, p.oid::regprocedure AS sig, pg_get_functiondef(p.oid) AS def
233
- FROM pg_proc p
234
- JOIN pg_namespace n ON n.oid = p.pronamespace
235
- WHERE n.nspname IN ('public', 'app', 'migration')
236
- AND p.prokind IN ('f', 'p')
237
- AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
238
- ORDER BY p.oid
239
- LOOP
240
- v_def := r.def;
241
- FOR i IN 1 .. array_length(v_names, 1) LOOP
242
- v_name := v_names[i];
243
- v_def := regexp_replace(v_def, '\mplg_' || v_name || '\M', v_name, 'g');
244
- END LOOP;
245
- IF v_def IS DISTINCT FROM r.def THEN
246
- EXECUTE v_def;
247
- v_count := v_count + 1;
248
- END IF;
249
- END LOOP;
250
- IF v_count > 0 THEN
251
- RAISE NOTICE '097: repointed % function(s) at the unprefixed core tables', v_count;
252
- END IF;
253
- END $$;
254
-
255
- -- ── §4 what the tables say about themselves ────────────────────────────────
256
- -- Guarded per table: the spine bench (packages/db/test/run-migrations.sh)
257
- -- applies a subset of the chain, so not every one of the eight is present in
258
- -- every place this file runs.
259
- DO $$
260
- DECLARE
261
- v_comments text[][] := ARRAY[
262
- ['analytics_read_models',
263
- 'Allowlist of read models analytics_run() may query. Core (spine 024): a view that is not registered here cannot be reached, however well-formed the card is.'],
264
- ['connections',
265
- 'One tenant''s connection to one external provider, per instance_key. Core (spine 029): the connector framework''s table, used by every connector and owned by none of them.'],
266
- ['connection_secrets',
267
- 'Credential holder for a connection — reachable only through plg_store_connection_secret / plg_read_connection_secret (spine 032). No client role has any privilege on it.'],
268
- ['connector_schedules',
269
- 'How often each connector needs waking, and which edge function + action to wake. Written by a connector''s own migration through plg_register_connector_schedule (spine 033). Granted to no client role: it decides how much of a provider''s quota the whole pool spends.'],
270
- ['entity_fields',
271
- 'The field registry (spine 035): the merchant-defined keys a tenant may write into an entity''s custom_fields, and the rules they are validated against.'],
272
- ['event_log',
273
- 'The domain event log (spine 027) — CORE, not plugin-automations''. plugins/plugin-automations 0002 also CREATEs it as the legacy first writer; the spine owns the definition and plg_emit_event is the only writer.'],
274
- ['sync_runs',
275
- 'One execution of the sync plane, per connection (spine 029). No INSERT grant for authenticated: a history the application can write is not a history.'],
276
- ['sync_schedule',
277
- 'The clock''s bookkeeping: when each connection is next due, what is in flight, how many consecutive failures. Not a history — sync_runs is. Rows are enrolled lazily by plg_sync_tick, with a deterministic offset so a cold start spreads instead of stampeding.']
278
- ];
279
- i integer;
280
- BEGIN
281
- FOR i IN 1 .. array_length(v_comments, 1) LOOP
282
- CONTINUE WHEN to_regclass('public.' || quote_ident(v_comments[i][1])) IS NULL;
283
- EXECUTE format('COMMENT ON TABLE public.%I IS %L', v_comments[i][1], v_comments[i][2]);
284
- END LOOP;
285
- END $$;
286
-
287
- -- ── §5 the access contract, re-asserted on the new names ───────────────────
288
- -- THE SHARP EDGE OF THIS RENAME, and the reason this section exists.
289
- --
290
- -- 007_archetype_grants and 008_grants hand `authenticated` and `service_role`
291
- -- `GRANT ALL ON ALL TABLES IN SCHEMA public`, and `anon` SELECT on all of them.
292
- -- Those are blanket statements over whatever exists when they run. Each of the
293
- -- eight tables then strips itself back in its own file (029/032/033/035) —
294
- -- `REVOKE ALL … FROM anon, authenticated`, then grants back only what the table
295
- -- needs.
296
- --
297
- -- On a REPLAY that pairing breaks: the blanket GRANT lands on the renamed
298
- -- table (`connections`), while the REVOKE in the frozen file lands on the empty
299
- -- `plg_connections` the same replay just re-created — so the tables would come
300
- -- out of a replay with `authenticated` holding INSERT/UPDATE/DELETE on the run
301
- -- history, the credential holder and the scheduler, and `anon` holding SELECT
302
- -- until 009 takes it back. The bench's F3e regression is exactly this, caught.
303
- --
304
- -- So the contract is restated here, on the new names, word for word from the
305
- -- files that own it. Guarded per table: the spine bench applies a subset of the
306
- -- chain, so not all eight exist everywhere this runs.
307
- DO $$
308
- DECLARE
309
- v_sql text[] := ARRAY[
310
- -- 024: the read-model allowlist is readable by any member, written by the migration role.
311
- 'GRANT SELECT ON public.analytics_read_models TO authenticated',
312
- 'GRANT ALL ON public.analytics_read_models TO service_role',
313
- -- 029: a member manages its connections and only READS their history.
314
- 'REVOKE ALL ON public.connections FROM anon, authenticated',
315
- 'REVOKE ALL ON public.sync_runs FROM anon, authenticated',
316
- 'GRANT SELECT, INSERT, UPDATE, DELETE ON public.connections TO authenticated',
317
- 'GRANT SELECT ON public.sync_runs TO authenticated',
318
- -- 032: the credential holder is reachable through its RPCs and nothing else.
319
- 'REVOKE ALL ON public.connection_secrets FROM PUBLIC',
320
- 'REVOKE ALL ON public.connection_secrets FROM anon, authenticated',
321
- -- 033: neither clock table is tenant data, and that is why both are shut.
322
- 'REVOKE ALL ON public.connector_schedules FROM PUBLIC',
323
- 'REVOKE ALL ON public.connector_schedules FROM anon, authenticated',
324
- 'REVOKE ALL ON public.sync_schedule FROM PUBLIC',
325
- 'REVOKE ALL ON public.sync_schedule FROM anon, authenticated',
326
- -- 035: a member edits its own field definitions, never another tenant's.
327
- 'REVOKE ALL ON public.entity_fields FROM anon, authenticated',
328
- 'GRANT SELECT, INSERT, UPDATE, DELETE ON public.entity_fields TO authenticated'
329
- ];
330
- i integer;
331
- v_tbl text;
332
- BEGIN
333
- FOR i IN 1 .. array_length(v_sql, 1) LOOP
334
- v_tbl := substring(v_sql[i] from 'public\.[a-z_]+');
335
- CONTINUE WHEN to_regclass(v_tbl) IS NULL;
336
- EXECUTE v_sql[i];
337
- END LOOP;
338
- END $$;
339
-
340
- -- ── §6 the one applied file this rename could not fix forward ──────────────
341
- -- Every consumer of the eight names is repointed either in place (source that
342
- -- is not an applied migration) or by a NEW migration in the owning package
343
- -- (plugin-banking-br 004, @fayz-ai/courses 0011, @fayz-ai/shop 0067) — an
344
- -- applied file is never edited. @fayz-ai/shop 0066_analytics_read_models.sql is
345
- -- the single exception: its `INSERT INTO public.plg_analytics_read_models` is a
346
- -- TOP-LEVEL statement, so on a fresh pool it would error before any later file
347
- -- could repair anything. It is edited, which moves its checksum.
348
- --
349
- -- Forget that one ledger row so the pool re-applies the file (it is idempotent:
350
- -- CREATE OR REPLACE VIEW + INSERT … ON CONFLICT DO UPDATE) instead of stopping
351
- -- on the drift guard forever. `fayz db pool apply` snapshots the ledger before
352
- -- the first file runs, so the apply that carries THIS migration still stops on
353
- -- 0066 and the NEXT one applies it — two passes, both safe, and the operator
354
- -- sees exactly which file moved. The bench's runner reads the ledger per file
355
- -- and converges in one.
356
- DO $$
357
- BEGIN
358
- IF to_regclass('public._migrations') IS NOT NULL THEN
359
- DELETE FROM public._migrations WHERE file_name = '0066_analytics_read_models.sql';
360
- END IF;
361
- END $$;