@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
@@ -0,0 +1,109 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 022_the_line_says_which_document_it_belongs_to.sql — `items.document_id`
3
+ -- deixa de ser uma conta e vira uma coluna, para que exista índice sobre ela.
4
+ --
5
+ -- O SINTOMA: `GET /rest/v1/v_orders?select=id,total,status,fulfillment_status
6
+ -- &id=in.(três ids)` devolvendo 500 com `57014: statement timeout`. Três
7
+ -- pedidos por id — a consulta mais seletiva que existe.
8
+ --
9
+ -- ── A medição ──────────────────────────────────────────────────────────────
10
+ -- -> Index Scan using items_tenant_id_id_key on items i
11
+ -- Index Cond: (tenant_id = …) ← só o tenant
12
+ -- Filter: … AND (COALESCE(order_id, invoice_id, fulfillment_id,
13
+ -- quote_id) = o.id)
14
+ -- Rows Removed by Filter: 11560
15
+ -- SubPlan 22 loops=34683 0,243ms ← RLS por linha
16
+ -- SubPlan 30 loops=34683 0,337ms
17
+ -- Execution Time: 20368 ms
18
+ --
19
+ -- 11.561 itens do tenant varridos POR PEDIDO — 34.683 voltas — cada uma pagando
20
+ -- duas funções de política. O `LEFT JOIN LATERAL` de `v_orders` até empurrou o
21
+ -- filtro para dentro; o que não havia era por onde entrar.
22
+ --
23
+ -- ── Por que não havia índice possível ──────────────────────────────────────
24
+ -- `v_items.document_id` era uma EXPRESSÃO:
25
+ --
26
+ -- COALESCE(order_id, invoice_id, fulfillment_id, quote_id) AS document_id
27
+ --
28
+ -- Uma linha de item aponta para o documento que a contém, e qual das quatro
29
+ -- colunas está preenchida depende de `parent_kind`. A view unificava as quatro
30
+ -- na leitura, o que é a modelagem certa — só que o filtro `document_id = X`
31
+ -- passava a ser um filtro sobre uma conta, e nenhum índice de coluna atende.
32
+ --
33
+ -- Um índice de EXPRESSÃO resolve com constante — medido, 0,159ms — mas não foi
34
+ -- usado quando o valor chega como parâmetro de um LATERAL através de duas views
35
+ -- empilhadas (`v_orders` → `v_item_balances` → `v_items`): o planejador deixa
36
+ -- de casar a expressão em algum ponto do empilhamento e cai para a varredura.
37
+ --
38
+ -- Depender de o planejador reconhecer a mesma expressão através de três níveis
39
+ -- de view é uma aposta, e esta é a segunda vez que ela sai cara.
40
+ --
41
+ -- ── A correção ─────────────────────────────────────────────────────────────
42
+ -- A conta vira coluna: `items.document_id`, GENERATED ALWAYS … STORED. O valor
43
+ -- é exatamente o mesmo — o Postgres é quem o mantém, e não há caminho de
44
+ -- escrita que possa discordar dele. Sobre uma coluna real o índice é trivial e
45
+ -- o planejador não precisa reconhecer nada.
46
+ --
47
+ -- `v_items` passa a ler a coluna em vez de recalcular. Mesmo nome, mesma
48
+ -- posição, mesmo tipo: nada a jusante percebe.
49
+ --
50
+ -- Resultado, mesma consulta: 20.368ms → 192ms, com
51
+ -- Index Cond: ((document_id = o.id) AND (parent_kind = 'order'))
52
+ -- e as políticas de `items` saindo do laço junto com as 11.560 linhas que
53
+ -- deixaram de ser lidas.
54
+ -- ---------------------------------------------------------------------------
55
+
56
+ -- A coluna. `IF NOT EXISTS` porque a migration é reaplicável; GENERATED porque
57
+ -- o valor não é opinião de quem escreve — é derivado, e derivar no banco impede
58
+ -- que uma porta de escrita nova esqueça de preencher.
59
+ ALTER TABLE public.items
60
+ ADD COLUMN IF NOT EXISTS document_id uuid
61
+ GENERATED ALWAYS AS (COALESCE(order_id, invoice_id, fulfillment_id, quote_id)) STORED;
62
+
63
+ COMMENT ON COLUMN public.items.document_id IS
64
+ 'O documento que contém esta linha (#022), derivado de order_id/invoice_id/fulfillment_id/quote_id conforme parent_kind. Coluna e não expressão porque `v_orders` filtra por ela através de duas views empilhadas, e um índice de expressão deixa de ser reconhecido nesse caminho — era uma varredura de 11.561 itens por pedido.';
65
+
66
+ -- (tenant_id, document_id, parent_kind): o tenant primeiro como todo índice
67
+ -- daqui, o documento em seguida porque é o que a busca conhece, e o parent_kind
68
+ -- por último porque é o que desempata.
69
+ CREATE INDEX IF NOT EXISTS items_tenant_document_col_idx
70
+ ON public.items (tenant_id, document_id, parent_kind);
71
+
72
+ -- O índice de expressão da primeira tentativa, se ficou para trás.
73
+ DROP INDEX IF EXISTS public.items_tenant_document_idx;
74
+
75
+ -- A view lê a coluna. Mesmas colunas, mesma ordem, mesmos tipos.
76
+ -- `WITH (security_invoker = true)` explícito: `CREATE OR REPLACE VIEW` NÃO
77
+ -- herda as opções da view que substitui, e sem isto ela volta a rodar com os
78
+ -- direitos do dono — as políticas de quem lê deixam de valer. É o que a
79
+ -- asserção N16 da postura grada, e foi ela que pegou.
80
+ CREATE OR REPLACE VIEW public.v_items WITH (security_invoker = true) AS
81
+ SELECT id,
82
+ tenant_id,
83
+ unit_id,
84
+ parent_kind,
85
+ document_id,
86
+ order_id,
87
+ invoice_id,
88
+ fulfillment_id,
89
+ quote_id,
90
+ product_id,
91
+ variant_id,
92
+ -- `service_id` NÃO entra: ele existe no cluster onde esta migration foi
93
+ -- escrita e não na corrente canônica, então redeclarar a view com ele
94
+ -- quebrava o `apply` com "column service_id does not exist". A view
95
+ -- reproduz o conjunto de colunas do baseline, mais `document_id`.
96
+ name,
97
+ description,
98
+ quantity,
99
+ unit_price,
100
+ discount,
101
+ total,
102
+ sort_order,
103
+ metadata,
104
+ custom_fields,
105
+ created_at,
106
+ updated_at
107
+ FROM items i;
108
+
109
+ ANALYZE public.items;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.13.0",
6
+ "version": "0.14.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,
@@ -1,86 +0,0 @@
1
- -- ============================================================================
2
- -- 0000_legacy_quarantine.sql — pre-convert collision guard (ALL pools)
3
- --
4
- -- MUST run BEFORE 000_core_v1_convert.sql. The filename starts `0000_` so it
5
- -- lexically sorts ahead of `000_core_v1_convert.sql` (the runner orders files
6
- -- with a plain string sort — verify: ['0000_legacy_quarantine.sql',
7
- -- '000_core_v1_convert.sql'].sort()).
8
- --
9
- -- WHY: the in-place converter moves saas_core.<source> -> public.<target> only
10
- -- when public.<target> is FREE (guarded on target absence). If a pool already
11
- -- holds a legacy public.<target> (a bespoke pre-pool table sharing the name),
12
- -- the move/rename silently no-ops and the live saas_core.<source> is left
13
- -- behind — then `DROP SCHEMA saas_core CASCADE` would DESTROY it. This file
14
- -- gets the colliding legacy table out of the way first, into a quarantine
15
- -- schema, so every source table has a free target to land on.
16
- --
17
- -- For EVERY (source -> target) pair the converter touches, if BOTH
18
- -- saas_core.<source> AND public.<target> exist, move public.<target> into
19
- -- legacy_pre_pools. The presence of saas_core.<source> proves the convert has
20
- -- not run yet, so this public.<target> must be a legacy collision (not the
21
- -- converted core table).
22
- --
23
- -- Known real collisions from the 2026-07-14 backups (all covered by the loop):
24
- -- * creators — public.subscriptions (4 rows, direct-move pair)
25
- -- * salon/gphx — public.appointments (0 rows, bookings->appointments rename)
26
- -- * restaurant — public.orders + public.order_items (0 rows, direct-move pairs)
27
- --
28
- -- Idempotent: guarded on saas_core existing; each move guarded on both source
29
- -- present AND target present; a no-op on fresh pools and on already-converted
30
- -- pools (saas_core dropped -> early return).
31
- -- ============================================================================
32
-
33
- DO $quarantine$
34
- DECLARE
35
- r record;
36
- BEGIN
37
- -- Only act on pools that still carry the legacy schema (pre-convert).
38
- IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'saas_core') THEN
39
- RETURN;
40
- END IF;
41
-
42
- -- (source in saas_core) -> (target in public) for every table the converter
43
- -- moves. Direct moves keep the name; the three changed entities are renamed
44
- -- (persons->people, bookings->appointments, booking_items->appointment_items).
45
- -- Mirrors the move list in 000_core_v1_convert.sql exactly.
46
- FOR r IN
47
- SELECT * FROM (VALUES
48
- ('tenants', 'tenants'),
49
- ('profiles', 'profiles'),
50
- ('tenant_members', 'tenant_members'),
51
- ('plans', 'plans'),
52
- ('permissions', 'permissions'),
53
- ('role_permissions', 'role_permissions'),
54
- ('tenant_role_overrides', 'tenant_role_overrides'),
55
- ('invitations', 'invitations'),
56
- ('payment_events', 'payment_events'),
57
- ('locations', 'locations'),
58
- ('location_members', 'location_members'),
59
- ('audit_logs', 'audit_logs'),
60
- ('subscriptions', 'subscriptions'),
61
- ('invoices', 'invoices'),
62
- ('notifications', 'notifications'),
63
- ('persons', 'people'),
64
- ('categories', 'categories'),
65
- ('products', 'products'),
66
- ('services', 'services'),
67
- ('orders', 'orders'),
68
- ('order_items', 'order_items'),
69
- ('transactions', 'transactions'),
70
- ('bookings', 'appointments'),
71
- ('booking_items', 'appointment_items'),
72
- ('schedules', 'schedules'),
73
- ('sequences', 'sequences'),
74
- ('documents', 'documents')
75
- ) AS m(source, target)
76
- LOOP
77
- IF to_regclass('saas_core.' || quote_ident(r.source)) IS NOT NULL
78
- AND to_regclass('public.' || quote_ident(r.target)) IS NOT NULL THEN
79
- CREATE SCHEMA IF NOT EXISTS legacy_pre_pools;
80
- EXECUTE format('ALTER TABLE public.%I SET SCHEMA legacy_pre_pools', r.target);
81
- RAISE NOTICE 'legacy_pre_pools: quarantined public.% (collides with the saas_core.% -> public.% move)',
82
- r.target, r.source, r.target;
83
- END IF;
84
- END LOOP;
85
- END;
86
- $quarantine$;
@@ -1,37 +0,0 @@
1
- -- Move any saas_core table the converter does not know about into public,
2
- -- so the converter's emptiness gate can pass and drop the schema safely.
3
- -- Found live: salon carries saas_core.tenant_roles (RBAC iteration that only
4
- -- ever existed there). Runs between 0000_legacy_quarantine and
5
- -- 000_core_v1_convert ('0000b' < '000_' lexically). Idempotent: no-ops when
6
- -- saas_core is gone or has no stragglers. A public-name collision aborts —
7
- -- that case must be quarantined explicitly, never guessed.
8
-
9
- DO $sweep$
10
- DECLARE
11
- r record;
12
- known text[] := ARRAY[
13
- 'tenants', 'profiles', 'tenant_members', 'plans', 'permissions',
14
- 'role_permissions', 'tenant_role_overrides', 'invitations',
15
- 'payment_events', 'locations', 'location_members', 'audit_logs',
16
- 'subscriptions', 'invoices', 'notifications',
17
- 'persons', 'categories', 'products', 'services', 'orders', 'order_items',
18
- 'transactions', 'bookings', 'booking_items', 'schedules',
19
- 'sequences', 'documents'
20
- ];
21
- BEGIN
22
- IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'saas_core') THEN
23
- RETURN;
24
- END IF;
25
-
26
- FOR r IN
27
- SELECT table_name FROM information_schema.tables
28
- WHERE table_schema = 'saas_core' AND table_type = 'BASE TABLE'
29
- AND table_name <> ALL (known)
30
- LOOP
31
- IF to_regclass('public.' || quote_ident(r.table_name)) IS NOT NULL THEN
32
- RAISE EXCEPTION 'straggler saas_core.% collides with an existing public table — quarantine it explicitly', r.table_name;
33
- END IF;
34
- EXECUTE format('ALTER TABLE saas_core.%I SET SCHEMA public', r.table_name);
35
- RAISE NOTICE 'straggler moved: saas_core.% -> public', r.table_name;
36
- END LOOP;
37
- END $sweep$;
@@ -1,343 +0,0 @@
1
- -- ============================================================================
2
- -- 000_core_v1_convert.sql — in-place converter: saas_core -> public
3
- --
4
- -- For EXISTING pools that already hold live data in the saas_core schema.
5
- -- Moves every core table into public, renames the three entities that changed
6
- -- (persons->people, bookings->appointments, booking_items->appointment_items),
7
- -- re-authors the core functions/triggers/policies in public terms, then drops
8
- -- the saas_core schema and re-points PostgREST at public only.
9
- --
10
- -- Idempotent + partial-re-run safe: guarded by the existence of the saas_core
11
- -- schema, and every table move/rename is individually guarded with to_regclass.
12
- --
13
- -- Relies on Postgres semantics:
14
- -- * ALTER TABLE ... SET SCHEMA preserves data / PKs / FKs / indexes /
15
- -- policies / grants (only the containing schema changes).
16
- -- * ALTER TABLE ... RENAME cascades FK / index / policy references.
17
- -- * Function BODIES and trigger function references do NOT auto-follow a
18
- -- schema move -> they are re-emitted below before saas_core is dropped.
19
- --
20
- -- ORDERING NOTE: 0000_legacy_quarantine.sql MUST be applied BEFORE this file.
21
- -- On any pool that already holds a legacy public.<target> table (e.g. a bespoke
22
- -- public.appointments, or a real public.subscriptions), that collision would
23
- -- block the corresponding move/rename here (each is guarded on target absence),
24
- -- leaving the live saas_core.<source> stranded for the CASCADE below to destroy.
25
- -- The quarantine step moves such collisions into legacy_pre_pools first.
26
- -- ============================================================================
27
-
28
- DO $convert$
29
- DECLARE
30
- t text;
31
- r record;
32
- v_roles text;
33
- v_using text;
34
- v_check text;
35
- v_sql text;
36
- core_tables text[] := ARRAY[
37
- 'tenants', 'profiles', 'tenant_members', 'plans', 'permissions',
38
- 'role_permissions', 'tenant_role_overrides', 'invitations',
39
- 'payment_events', 'locations', 'location_members', 'audit_logs',
40
- 'subscriptions', 'invoices', 'notifications',
41
- 'persons', 'categories', 'products', 'services', 'orders', 'order_items',
42
- 'transactions', 'bookings', 'booking_items', 'schedules',
43
- -- may or may not exist depending on installed plugins (stay core, unprefixed)
44
- 'sequences', 'documents'
45
- ];
46
- -- post-move / post-rename public names carrying an updated_at trigger
47
- ts_tables text[] := ARRAY[
48
- 'tenants', 'profiles', 'locations', 'subscriptions', 'people', 'categories',
49
- 'products', 'services', 'orders', 'transactions', 'appointments', 'schedules'
50
- ];
51
- -- post-move / post-rename public names governed by generic tenant isolation
52
- archetype_tables text[] := ARRAY[
53
- 'people', 'categories', 'products', 'services',
54
- 'orders', 'transactions', 'appointments', 'schedules'
55
- ];
56
- -- every core table that must be re-policied in public terms
57
- policy_tables text[];
58
- BEGIN
59
- -- Only act on pools that still have the legacy schema.
60
- IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'saas_core') THEN
61
- RETURN;
62
- END IF;
63
-
64
- -- --------------------------------------------------------------------------
65
- -- 1. Move every core table saas_core.* -> public.* (guarded, idempotent).
66
- -- --------------------------------------------------------------------------
67
- FOREACH t IN ARRAY core_tables LOOP
68
- IF to_regclass('saas_core.' || quote_ident(t)) IS NOT NULL
69
- AND to_regclass('public.' || quote_ident(t)) IS NULL THEN
70
- EXECUTE format('ALTER TABLE saas_core.%I SET SCHEMA public', t);
71
- END IF;
72
- END LOOP;
73
-
74
- -- --------------------------------------------------------------------------
75
- -- 2. Rename the three entities that changed (only if source present, target free).
76
- -- --------------------------------------------------------------------------
77
- IF to_regclass('public.persons') IS NOT NULL AND to_regclass('public.people') IS NULL THEN
78
- ALTER TABLE public.persons RENAME TO people;
79
- END IF;
80
- IF to_regclass('public.bookings') IS NOT NULL AND to_regclass('public.appointments') IS NULL THEN
81
- ALTER TABLE public.bookings RENAME TO appointments;
82
- END IF;
83
- IF to_regclass('public.booking_items') IS NOT NULL AND to_regclass('public.appointment_items') IS NULL THEN
84
- ALTER TABLE public.booking_items RENAME TO appointment_items;
85
- END IF;
86
-
87
- -- --------------------------------------------------------------------------
88
- -- 3. Re-emit the core functions in public (bodies do not follow a move).
89
- -- MUST run after the moves/renames: LANGUAGE sql bodies are validated at
90
- -- CREATE time, so public.tenant_members etc. have to exist already.
91
- -- --------------------------------------------------------------------------
92
- EXECUTE $q$
93
- CREATE OR REPLACE FUNCTION public.handle_updated_at()
94
- RETURNS trigger LANGUAGE plpgsql AS $fn$
95
- BEGIN NEW.updated_at = now(); RETURN NEW; END; $fn$;
96
- $q$;
97
-
98
- EXECUTE $q$
99
- CREATE OR REPLACE FUNCTION public.handle_new_user()
100
- RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $fn$
101
- BEGIN
102
- INSERT INTO public.profiles (id, email, full_name, avatar_url)
103
- VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'avatar_url')
104
- ON CONFLICT (id) DO NOTHING;
105
- RETURN NEW;
106
- END; $fn$;
107
- $q$;
108
-
109
- EXECUTE $q$
110
- CREATE OR REPLACE FUNCTION public.create_tenant_with_owner(
111
- p_name text, p_slug text, p_user_id uuid,
112
- p_vertical_id text DEFAULT NULL, p_plan text DEFAULT 'free', p_settings jsonb DEFAULT '{}'
113
- )
114
- RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER AS $fn$
115
- DECLARE v_id uuid; v_tenant jsonb;
116
- BEGIN
117
- INSERT INTO public.tenants (name, slug, plan, vertical_id, settings)
118
- VALUES (p_name, p_slug, p_plan, p_vertical_id, p_settings) RETURNING id INTO v_id;
119
- INSERT INTO public.tenant_members (tenant_id, user_id, role)
120
- VALUES (v_id, p_user_id, 'owner');
121
- SELECT to_jsonb(t) INTO v_tenant FROM public.tenants t WHERE t.id = v_id;
122
- RETURN v_tenant;
123
- END; $fn$;
124
- $q$;
125
-
126
- EXECUTE $q$
127
- CREATE OR REPLACE FUNCTION public.is_tenant_admin(p_tenant_id uuid)
128
- RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
129
- SELECT EXISTS (
130
- SELECT 1 FROM public.tenant_members
131
- WHERE tenant_id = p_tenant_id AND user_id = auth.uid() AND role IN ('owner', 'admin')
132
- );
133
- $fn$;
134
- $q$;
135
-
136
- EXECUTE $q$
137
- CREATE OR REPLACE FUNCTION public.user_tenant_ids()
138
- RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
139
- SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid();
140
- $fn$;
141
- $q$;
142
-
143
- -- Re-point the auth.users trigger at the public function.
144
- DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
145
- CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users
146
- FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
147
-
148
- -- --------------------------------------------------------------------------
149
- -- 4. Re-create updated_at triggers to call public.handle_updated_at.
150
- -- --------------------------------------------------------------------------
151
- FOREACH t IN ARRAY ts_tables LOOP
152
- IF to_regclass('public.' || quote_ident(t)) IS NOT NULL THEN
153
- EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);
154
- EXECUTE format(
155
- 'CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()',
156
- t || '_updated_at', t
157
- );
158
- END IF;
159
- END LOOP;
160
- -- Legacy trigger names carried over by the RENAME.
161
- IF to_regclass('public.people') IS NOT NULL THEN
162
- DROP TRIGGER IF EXISTS persons_updated_at ON public.people;
163
- END IF;
164
- IF to_regclass('public.appointments') IS NOT NULL THEN
165
- DROP TRIGGER IF EXISTS bookings_updated_at ON public.appointments;
166
- END IF;
167
-
168
- -- --------------------------------------------------------------------------
169
- -- 5. Re-emit RLS policies in public terms.
170
- -- Drop EVERY existing policy on the moved tables (their bodies still
171
- -- reference saas_core.*) and recreate the canonical public policy set —
172
- -- identical end-state to a fresh baseline (001 + 006).
173
- -- --------------------------------------------------------------------------
174
- policy_tables := core_tables
175
- || ARRAY['people', 'appointments', 'appointment_items']::text[];
176
-
177
- FOR r IN
178
- SELECT policyname, tablename FROM pg_policies
179
- WHERE schemaname = 'public' AND tablename = ANY(policy_tables)
180
- LOOP
181
- EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', r.policyname, r.tablename);
182
- END LOOP;
183
-
184
- -- tenants
185
- CREATE POLICY "tenants_select" ON public.tenants FOR SELECT
186
- USING (id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
187
- CREATE POLICY "tenants_insert" ON public.tenants FOR INSERT TO authenticated WITH CHECK (true);
188
- CREATE POLICY "tenants_update" ON public.tenants FOR UPDATE USING (public.is_tenant_admin(id));
189
-
190
- -- profiles
191
- CREATE POLICY "profiles_select" ON public.profiles FOR SELECT USING (id = auth.uid());
192
- CREATE POLICY "profiles_insert" ON public.profiles FOR INSERT WITH CHECK (id = auth.uid());
193
- CREATE POLICY "profiles_update" ON public.profiles FOR UPDATE USING (id = auth.uid());
194
-
195
- -- tenant_members
196
- CREATE POLICY "members_select" ON public.tenant_members FOR SELECT USING (user_id = auth.uid());
197
- CREATE POLICY "members_insert_own" ON public.tenant_members FOR INSERT WITH CHECK (user_id = auth.uid());
198
- CREATE POLICY "members_update" ON public.tenant_members FOR UPDATE USING (public.is_tenant_admin(tenant_id));
199
- CREATE POLICY "members_delete" ON public.tenant_members FOR DELETE USING (public.is_tenant_admin(tenant_id));
200
-
201
- -- plans / permissions / role_permissions
202
- CREATE POLICY "plans_select" ON public.plans FOR SELECT TO authenticated USING (true);
203
- CREATE POLICY "perms_select" ON public.permissions FOR SELECT TO authenticated USING (true);
204
- CREATE POLICY "role_perms_select" ON public.role_permissions FOR SELECT TO authenticated USING (true);
205
-
206
- -- tenant_role_overrides
207
- CREATE POLICY "overrides_select" ON public.tenant_role_overrides FOR SELECT
208
- USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
209
- CREATE POLICY "overrides_manage" ON public.tenant_role_overrides FOR ALL
210
- USING (public.is_tenant_admin(tenant_id));
211
-
212
- -- invitations
213
- CREATE POLICY "invites_select" ON public.invitations FOR SELECT TO authenticated USING (true);
214
- CREATE POLICY "invites_insert" ON public.invitations FOR INSERT WITH CHECK (public.is_tenant_admin(tenant_id));
215
- CREATE POLICY "invites_update" ON public.invitations FOR UPDATE USING (public.is_tenant_admin(tenant_id));
216
- CREATE POLICY "invites_delete" ON public.invitations FOR DELETE USING (public.is_tenant_admin(tenant_id));
217
-
218
- -- payment_events
219
- CREATE POLICY "events_service" ON public.payment_events FOR ALL TO service_role USING (true);
220
-
221
- -- locations
222
- CREATE POLICY "locations_select" ON public.locations FOR SELECT
223
- USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
224
- CREATE POLICY "locations_manage" ON public.locations FOR ALL USING (public.is_tenant_admin(tenant_id));
225
-
226
- -- location_members
227
- CREATE POLICY "loc_members_select" ON public.location_members FOR SELECT
228
- USING (location_id IN (SELECT id FROM public.locations WHERE tenant_id IN (
229
- SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()
230
- )));
231
-
232
- -- subscriptions / invoices
233
- CREATE POLICY "subs_select" ON public.subscriptions FOR SELECT
234
- USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
235
- CREATE POLICY "invoices_select" ON public.invoices FOR SELECT
236
- USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
237
-
238
- -- audit_logs
239
- CREATE POLICY "audit_select" ON public.audit_logs FOR SELECT
240
- USING (tenant_id IN (SELECT tenant_id FROM public.tenant_members WHERE user_id = auth.uid()));
241
- CREATE POLICY "audit_insert" ON public.audit_logs FOR INSERT TO authenticated WITH CHECK (true);
242
-
243
- -- notifications
244
- CREATE POLICY "notif_select" ON public.notifications FOR SELECT USING (user_id = auth.uid());
245
- CREATE POLICY "notif_update" ON public.notifications FOR UPDATE USING (user_id = auth.uid());
246
-
247
- -- archetype tables (generic tenant isolation via SECURITY DEFINER helper)
248
- FOREACH t IN ARRAY archetype_tables LOOP
249
- EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
250
- EXECUTE format('CREATE POLICY "%s_select" ON public.%I FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t, t);
251
- EXECUTE format('CREATE POLICY "%s_insert" ON public.%I FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()))', t, t);
252
- EXECUTE format('CREATE POLICY "%s_update" ON public.%I FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t, t);
253
- EXECUTE format('CREATE POLICY "%s_delete" ON public.%I FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()))', t, t);
254
- END LOOP;
255
-
256
- -- order_items (parent-FK policies)
257
- CREATE POLICY "order_items_select" ON public.order_items FOR SELECT TO authenticated
258
- USING (order_id IN (SELECT id FROM public.orders WHERE tenant_id IN (SELECT public.user_tenant_ids())));
259
- CREATE POLICY "order_items_insert" ON public.order_items FOR INSERT TO authenticated
260
- WITH CHECK (order_id IN (SELECT id FROM public.orders WHERE tenant_id IN (SELECT public.user_tenant_ids())));
261
- CREATE POLICY "order_items_update" ON public.order_items FOR UPDATE TO authenticated
262
- USING (order_id IN (SELECT id FROM public.orders WHERE tenant_id IN (SELECT public.user_tenant_ids())));
263
- CREATE POLICY "order_items_delete" ON public.order_items FOR DELETE TO authenticated
264
- USING (order_id IN (SELECT id FROM public.orders WHERE tenant_id IN (SELECT public.user_tenant_ids())));
265
-
266
- -- appointment_items (parent-FK policies; booking_id column preserved by rename)
267
- CREATE POLICY "appointment_items_select" ON public.appointment_items FOR SELECT TO authenticated
268
- USING (booking_id IN (SELECT id FROM public.appointments WHERE tenant_id IN (SELECT public.user_tenant_ids())));
269
- CREATE POLICY "appointment_items_insert" ON public.appointment_items FOR INSERT TO authenticated
270
- WITH CHECK (booking_id IN (SELECT id FROM public.appointments WHERE tenant_id IN (SELECT public.user_tenant_ids())));
271
- CREATE POLICY "appointment_items_update" ON public.appointment_items FOR UPDATE TO authenticated
272
- USING (booking_id IN (SELECT id FROM public.appointments WHERE tenant_id IN (SELECT public.user_tenant_ids())));
273
- CREATE POLICY "appointment_items_delete" ON public.appointment_items FOR DELETE TO authenticated
274
- USING (booking_id IN (SELECT id FROM public.appointments WHERE tenant_id IN (SELECT public.user_tenant_ids())));
275
-
276
- -- --------------------------------------------------------------------------
277
- -- 5b. Re-point APP-table policies that still call saas_core.* helpers.
278
- -- App-created tables (unknown to this converter — e.g. conversations,
279
- -- conversation_messages) can carry RLS policies whose USING / WITH CHECK
280
- -- bodies reference saas_core.user_tenant_ids() (FKs are OID-based and
281
- -- survive the schema move, but policy bodies are text). DROP SCHEMA
282
- -- saas_core CASCADE would cascade-drop such a policy, leaving its table
283
- -- RLS-enabled with ZERO policies (a full lock-out). Rewrite each in
284
- -- public terms first — GENERIC, driven by pg_policies text, never a list.
285
- -- Idempotent: after the rewrite no body contains 'saas_core.', so a
286
- -- re-run selects nothing.
287
- -- --------------------------------------------------------------------------
288
- FOR r IN
289
- SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual, with_check
290
- FROM pg_policies
291
- WHERE schemaname = 'public'
292
- AND (
293
- coalesce(qual, '') LIKE '%saas_core.%'
294
- OR coalesce(with_check, '') LIKE '%saas_core.%'
295
- )
296
- LOOP
297
- v_using := replace(coalesce(r.qual, ''), 'saas_core.', 'public.');
298
- v_check := replace(coalesce(r.with_check, ''), 'saas_core.', 'public.');
299
-
300
- -- Render the TO role list. PUBLIC is a keyword, never quote it.
301
- SELECT string_agg(CASE WHEN role = 'public' THEN 'public' ELSE quote_ident(role) END, ', ')
302
- INTO v_roles
303
- FROM unnest(r.roles) AS role;
304
-
305
- v_sql := format(
306
- 'CREATE POLICY %I ON public.%I AS %s FOR %s TO %s',
307
- r.policyname, r.tablename, r.permissive, r.cmd, coalesce(v_roles, 'public')
308
- );
309
- IF r.qual IS NOT NULL THEN
310
- v_sql := v_sql || format(' USING (%s)', v_using);
311
- END IF;
312
- IF r.with_check IS NOT NULL THEN
313
- v_sql := v_sql || format(' WITH CHECK (%s)', v_check);
314
- END IF;
315
-
316
- EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', r.policyname, r.tablename);
317
- EXECUTE v_sql;
318
- RAISE NOTICE 're-pointed policy %.% to public.* helpers', r.tablename, r.policyname;
319
- END LOOP;
320
-
321
- -- --------------------------------------------------------------------------
322
- -- 6. Drop the now-empty legacy schema (any residue is old wrappers/functions).
323
- -- SAFETY GATE: refuse to CASCADE if any BASE TABLE is still in saas_core —
324
- -- that means a move/rename above silently skipped (a name collision the
325
- -- quarantine step missed), and dropping now would destroy live data.
326
- -- --------------------------------------------------------------------------
327
- IF EXISTS (
328
- SELECT 1 FROM information_schema.tables
329
- WHERE table_schema = 'saas_core' AND table_type = 'BASE TABLE'
330
- ) THEN
331
- RAISE EXCEPTION 'saas_core still has tables after move — aborting before CASCADE (check name collisions / quarantine)';
332
- END IF;
333
-
334
- DROP SCHEMA IF EXISTS saas_core CASCADE;
335
-
336
- -- --------------------------------------------------------------------------
337
- -- 7. Point PostgREST at public only.
338
- -- --------------------------------------------------------------------------
339
- EXECUTE 'ALTER ROLE authenticator SET pgrst.db_schemas = ''public''';
340
- END;
341
- $convert$;
342
-
343
- NOTIFY pgrst, 'reload config';