@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,331 +0,0 @@
1
- -- ============================================================================
2
- -- 149_the_event_log_gets_a_consumer.sql — the log stops being a pile of rows
3
- -- nobody reads (#277).
4
- --
5
- -- The decision (rodada 3, Q2): the domain-event log is the CONTRACT between
6
- -- plugins for business effect; the in-memory bus stays, demoted to reactions on
7
- -- a screen. The reason is not taste — the bus only reaches listeners inside the
8
- -- app process, so an order placed on the storefront at 2am, an import in bulk,
9
- -- or a plugin running in another cluster all emit into silence. A row in a table
10
- -- survives all three, and survives the consumer being down.
11
- --
12
- -- What was missing was the consumer. 027 built the emitter and the ledger, and
13
- -- said so plainly in the status vocabulary: `recorded` means "the trigger fired
14
- -- and nobody acted yet (today's state: the catalog exists, message dispatch does
15
- -- not)". This file is that missing half.
16
- --
17
- -- THREE THINGS IT HAS TO GET RIGHT, and how:
18
- --
19
- -- Idempotence — a fact must not act twice. Delivery is a row keyed
20
- -- (event, plugin, handler): the consumer inserts it BEFORE calling, so a
21
- -- double run collides instead of double-charging. This is the same shape as
22
- -- the effect claim in 030.
23
- --
24
- -- Concurrency — two consumers must not fight. Claiming is
25
- -- `FOR UPDATE SKIP LOCKED`, so a second worker takes the next fact instead of
26
- -- waiting on the first.
27
- --
28
- -- Failure — a handler that throws must not stop the queue or lose the fact.
29
- -- Each delivery runs in its own subtransaction: it fails alone, records why,
30
- -- and is retried with backoff until `max_attempts`, after which it is
31
- -- `failed` and visible rather than retried forever.
32
- --
33
- -- WHAT THIS FILE DOES NOT DO. It does not schedule anything. Who calls
34
- -- `consume_event_log()` — pg_cron, an edge function, the app — is deployment,
35
- -- and a migration that assumed one would be wrong in every pool that uses
36
- -- another.
37
- -- ============================================================================
38
-
39
- -- ─────────────────────────────────────────────────────────────────────────
40
- -- §1 Subscriptions — a plugin declares what it reacts to, as a row
41
- -- ─────────────────────────────────────────────────────────────────────────
42
-
43
- CREATE TABLE IF NOT EXISTS public.event_subscriptions (
44
- plugin text NOT NULL,
45
- event_key text NOT NULL,
46
- handler text NOT NULL,
47
- description text,
48
- max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts BETWEEN 1 AND 50),
49
- is_active boolean NOT NULL DEFAULT true,
50
- created_at timestamptz NOT NULL DEFAULT now(),
51
- updated_at timestamptz NOT NULL DEFAULT now(),
52
- PRIMARY KEY (plugin, event_key, handler),
53
- CONSTRAINT event_subscriptions_handler_shape CHECK (handler ~ '^[a-z][a-z0-9_]*$')
54
- );
55
-
56
- COMMENT ON TABLE public.event_subscriptions IS
57
- 'Who reacts to which fact (149). A plugin declares its subscription as a ROW in its own migration — never by DDL on someone else''s table. handler is the name of a public function taking (jsonb) and returning jsonb.';
58
- COMMENT ON COLUMN public.event_subscriptions.handler IS
59
- 'A public function name. It receives the whole event row as jsonb and returns jsonb (its result, recorded on the delivery). Raising is how it says it failed.';
60
-
61
- ALTER TABLE public.event_subscriptions ENABLE ROW LEVEL SECURITY;
62
- DROP POLICY IF EXISTS event_subscriptions_read ON public.event_subscriptions;
63
- CREATE POLICY event_subscriptions_read ON public.event_subscriptions
64
- FOR SELECT TO authenticated, service_role USING (true);
65
- GRANT SELECT ON public.event_subscriptions TO authenticated, service_role;
66
- GRANT ALL ON public.event_subscriptions TO service_role;
67
- REVOKE INSERT, UPDATE, DELETE ON public.event_subscriptions FROM anon, authenticated;
68
-
69
- DROP TRIGGER IF EXISTS event_subscriptions_updated_at ON public.event_subscriptions;
70
- CREATE TRIGGER event_subscriptions_updated_at BEFORE UPDATE ON public.event_subscriptions
71
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
72
-
73
- CREATE OR REPLACE FUNCTION public.register_event_subscription(
74
- p_plugin text,
75
- p_event_key text,
76
- p_handler text,
77
- p_description text DEFAULT NULL,
78
- p_max_attempts integer DEFAULT 5
79
- ) RETURNS void
80
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
81
- AS $$
82
- BEGIN
83
- INSERT INTO public.event_subscriptions (plugin, event_key, handler, description, max_attempts)
84
- VALUES (p_plugin, p_event_key, p_handler, p_description, p_max_attempts)
85
- ON CONFLICT (plugin, event_key, handler) DO UPDATE
86
- SET description = coalesce(EXCLUDED.description, public.event_subscriptions.description),
87
- max_attempts = EXCLUDED.max_attempts,
88
- is_active = true;
89
- END $$;
90
-
91
- REVOKE ALL ON FUNCTION public.register_event_subscription(text, text, text, text, integer) FROM public, anon, authenticated;
92
- GRANT EXECUTE ON FUNCTION public.register_event_subscription(text, text, text, text, integer) TO service_role;
93
-
94
- -- ─────────────────────────────────────────────────────────────────────────
95
- -- §2 Deliveries — one row per (fact, subscriber), which is what makes a
96
- -- second run harmless
97
- -- ─────────────────────────────────────────────────────────────────────────
98
-
99
- CREATE TABLE IF NOT EXISTS public.event_deliveries (
100
- event_id uuid NOT NULL REFERENCES public.event_log(id) ON DELETE CASCADE,
101
- plugin text NOT NULL,
102
- handler text NOT NULL,
103
- status text NOT NULL DEFAULT 'pending'
104
- CHECK (status IN ('pending', 'delivered', 'failed', 'abandoned')),
105
- attempts integer NOT NULL DEFAULT 0,
106
- next_attempt_at timestamptz NOT NULL DEFAULT now(),
107
- result jsonb,
108
- error text,
109
- created_at timestamptz NOT NULL DEFAULT now(),
110
- updated_at timestamptz NOT NULL DEFAULT now(),
111
- PRIMARY KEY (event_id, plugin, handler)
112
- );
113
-
114
- COMMENT ON TABLE public.event_deliveries IS
115
- 'One row per (fact, subscriber) — the idempotence guarantee (149). The consumer inserts the row before calling the handler, so a second run collides instead of acting twice. `abandoned` is a delivery that used up max_attempts: still visible, no longer retried.';
116
-
117
- CREATE INDEX IF NOT EXISTS event_deliveries_due
118
- ON public.event_deliveries (next_attempt_at)
119
- WHERE status = 'pending';
120
-
121
- ALTER TABLE public.event_deliveries ENABLE ROW LEVEL SECURITY;
122
- DROP POLICY IF EXISTS event_deliveries_read ON public.event_deliveries;
123
- CREATE POLICY event_deliveries_read ON public.event_deliveries
124
- FOR SELECT TO authenticated, service_role USING (true);
125
- GRANT SELECT ON public.event_deliveries TO authenticated, service_role;
126
- GRANT ALL ON public.event_deliveries TO service_role;
127
- REVOKE INSERT, UPDATE, DELETE ON public.event_deliveries FROM anon, authenticated;
128
-
129
- DROP TRIGGER IF EXISTS event_deliveries_updated_at ON public.event_deliveries;
130
- CREATE TRIGGER event_deliveries_updated_at BEFORE UPDATE ON public.event_deliveries
131
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
132
-
133
- -- ─────────────────────────────────────────────────────────────────────────
134
- -- §3 The consumer
135
- --
136
- -- Claims up to p_limit facts that still have work, delivers each subscription in
137
- -- its own subtransaction, and moves the fact's status only when every
138
- -- subscription has finished. Returns a summary so a scheduler can log something
139
- -- meaningful and back off when there is nothing to do.
140
- -- ─────────────────────────────────────────────────────────────────────────
141
-
142
- CREATE OR REPLACE FUNCTION public.consume_event_log(
143
- p_limit integer DEFAULT 100,
144
- p_event_key text DEFAULT NULL
145
- ) RETURNS TABLE (
146
- events_claimed integer,
147
- deliveries_ok integer,
148
- deliveries_failed integer,
149
- deliveries_abandoned integer
150
- )
151
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
152
- AS $$
153
- DECLARE
154
- v_event record;
155
- v_sub record;
156
- v_result jsonb;
157
- v_attempts integer;
158
- v_claimed integer := 0;
159
- v_ok integer := 0;
160
- v_failed integer := 0;
161
- v_abandoned integer := 0;
162
- v_open integer;
163
- BEGIN
164
- FOR v_event IN
165
- SELECT e.*
166
- FROM public.event_log e
167
- WHERE e.status = 'recorded'
168
- AND (p_event_key IS NULL OR e.event_key = p_event_key)
169
- ORDER BY e.occurred_at
170
- LIMIT greatest(p_limit, 0)
171
- FOR UPDATE SKIP LOCKED
172
- LOOP
173
- v_claimed := v_claimed + 1;
174
-
175
- -- No subscriber is not a failure: the fact is recorded and nobody asked for
176
- -- it. `skipped` is 027's own word for that.
177
- IF NOT EXISTS (
178
- SELECT 1 FROM public.event_subscriptions s
179
- WHERE s.event_key = v_event.event_key AND s.is_active
180
- ) THEN
181
- UPDATE public.event_log SET status = 'skipped' WHERE id = v_event.id;
182
- CONTINUE;
183
- END IF;
184
-
185
- FOR v_sub IN
186
- SELECT s.* FROM public.event_subscriptions s
187
- WHERE s.event_key = v_event.event_key AND s.is_active
188
- ORDER BY s.plugin, s.handler
189
- LOOP
190
- -- The claim comes first: this row existing is what a second consumer
191
- -- collides with.
192
- INSERT INTO public.event_deliveries (event_id, plugin, handler)
193
- VALUES (v_event.id, v_sub.plugin, v_sub.handler)
194
- ON CONFLICT (event_id, plugin, handler) DO NOTHING;
195
-
196
- SELECT d.attempts INTO v_attempts
197
- FROM public.event_deliveries d
198
- WHERE d.event_id = v_event.id AND d.plugin = v_sub.plugin AND d.handler = v_sub.handler
199
- AND d.status = 'pending' AND d.next_attempt_at <= now()
200
- FOR UPDATE SKIP LOCKED;
201
-
202
- IF NOT FOUND THEN
203
- CONTINUE; -- already delivered, abandoned, or not due yet
204
- END IF;
205
-
206
- BEGIN
207
- EXECUTE format('SELECT public.%I($1)', v_sub.handler)
208
- INTO v_result
209
- USING to_jsonb(v_event);
210
-
211
- UPDATE public.event_deliveries
212
- SET status = 'delivered', attempts = v_attempts + 1, result = v_result, error = NULL
213
- WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
214
- v_ok := v_ok + 1;
215
-
216
- EXCEPTION WHEN OTHERS THEN
217
- -- Fails alone. The subtransaction rolls back whatever the handler did,
218
- -- so a half-applied effect cannot survive its own error.
219
- IF v_attempts + 1 >= v_sub.max_attempts THEN
220
- UPDATE public.event_deliveries
221
- SET status = 'abandoned', attempts = v_attempts + 1, error = SQLERRM
222
- WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
223
- v_abandoned := v_abandoned + 1;
224
- ELSE
225
- UPDATE public.event_deliveries
226
- SET status = 'pending', attempts = v_attempts + 1, error = SQLERRM,
227
- -- Backoff, capped: 2^n seconds up to an hour.
228
- next_attempt_at = now() + least(interval '1 hour', (power(2, v_attempts + 1) || ' seconds')::interval)
229
- WHERE event_id = v_event.id AND plugin = v_sub.plugin AND handler = v_sub.handler;
230
- v_failed := v_failed + 1;
231
- END IF;
232
- END;
233
- END LOOP;
234
-
235
- -- The fact moves only when nothing is still owed.
236
- SELECT count(*)::int INTO v_open
237
- FROM public.event_deliveries d
238
- WHERE d.event_id = v_event.id AND d.status = 'pending';
239
-
240
- IF v_open = 0 THEN
241
- UPDATE public.event_log
242
- SET status = CASE
243
- WHEN EXISTS (SELECT 1 FROM public.event_deliveries d WHERE d.event_id = v_event.id AND d.status = 'abandoned')
244
- THEN 'failed' ELSE 'sent' END,
245
- result = (SELECT jsonb_object_agg(d.plugin || '.' || d.handler, coalesce(d.result, to_jsonb(d.status)))
246
- FROM public.event_deliveries d WHERE d.event_id = v_event.id),
247
- error = (SELECT string_agg(d.plugin || '.' || d.handler || ': ' || d.error, '; ')
248
- FROM public.event_deliveries d WHERE d.event_id = v_event.id AND d.error IS NOT NULL)
249
- WHERE id = v_event.id;
250
- END IF;
251
- END LOOP;
252
-
253
- RETURN QUERY SELECT v_claimed, v_ok, v_failed, v_abandoned;
254
- END $$;
255
-
256
- COMMENT ON FUNCTION public.consume_event_log(integer, text) IS
257
- 'Delivers recorded facts to their declared subscribers (149). Idempotent by (event, plugin, handler); concurrent-safe by SKIP LOCKED; a failing handler fails alone, is retried with capped backoff and is abandoned — visibly — after max_attempts. Scheduling is deployment, not schema.';
258
-
259
- REVOKE ALL ON FUNCTION public.consume_event_log(integer, text) FROM public, anon, authenticated;
260
- GRANT EXECUTE ON FUNCTION public.consume_event_log(integer, text) TO service_role;
261
-
262
- -- ─────────────────────────────────────────────────────────────────────────
263
- -- §4 What the queue looks like from outside
264
- -- ─────────────────────────────────────────────────────────────────────────
265
-
266
- CREATE OR REPLACE VIEW public.v_event_queue
267
- WITH (security_invoker = true) AS
268
- SELECT e.event_key,
269
- e.status,
270
- count(*)::bigint AS facts,
271
- min(e.occurred_at) AS oldest,
272
- count(*) FILTER (WHERE d.status = 'pending')::bigint AS deliveries_pending,
273
- count(*) FILTER (WHERE d.status = 'abandoned')::bigint AS deliveries_abandoned
274
- FROM public.event_log e
275
- LEFT JOIN public.event_deliveries d ON d.event_id = e.id
276
- GROUP BY e.event_key, e.status;
277
-
278
- COMMENT ON VIEW public.v_event_queue IS
279
- 'The queue at a glance (149): facts by key and status, with what is still owed and what was abandoned. "Why did the stock not move?" is answered here rather than by asking each plugin.';
280
-
281
- GRANT SELECT ON public.v_event_queue TO authenticated, service_role;
282
-
283
- -- ─────────────────────────────────────────────────────────────────────────
284
- -- §5 The first fact worth reacting to: a Venda reaches completed
285
- --
286
- -- Emitted AFTER the row is written, so the fact is recorded only if the sale
287
- -- actually happened, and outside the writer's decision — nothing a subscriber
288
- -- does can roll the sale back. That asymmetry is the point of the log.
289
- --
290
- -- Only the transition INTO a completed state emits: an UPDATE that touches a
291
- -- note on an already-completed order is not a new fact.
292
- -- ─────────────────────────────────────────────────────────────────────────
293
-
294
- CREATE OR REPLACE FUNCTION public.trg_orders_emit_completed()
295
- RETURNS trigger
296
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
297
- AS $$
298
- BEGIN
299
- IF NEW.status IS DISTINCT FROM 'completed' THEN
300
- RETURN NULL;
301
- END IF;
302
- IF TG_OP = 'UPDATE' AND OLD.status IS NOT DISTINCT FROM 'completed' THEN
303
- RETURN NULL;
304
- END IF;
305
-
306
- PERFORM public.plg_emit_event(
307
- 'order.completed',
308
- jsonb_build_object(
309
- 'order_id', NEW.id,
310
- 'kind', NEW.kind,
311
- 'unit_id', NEW.unit_id,
312
- 'party_id', NEW.party_id,
313
- 'total', NEW.total
314
- ),
315
- 'order',
316
- NEW.id::text,
317
- NEW.tenant_id,
318
- NULL
319
- );
320
- RETURN NULL;
321
- END $$;
322
-
323
- COMMENT ON FUNCTION public.trg_orders_emit_completed() IS
324
- 'Records the fact that a Venda completed (149). AFTER, so the fact follows the write; only on the transition, so an edit to a completed order is not a second fact.';
325
-
326
- REVOKE ALL ON FUNCTION public.trg_orders_emit_completed() FROM public, anon, authenticated;
327
-
328
- DROP TRIGGER IF EXISTS orders_emit_completed ON public.orders;
329
- CREATE TRIGGER orders_emit_completed
330
- AFTER INSERT OR UPDATE OF status ON public.orders
331
- FOR EACH ROW EXECUTE FUNCTION public.trg_orders_emit_completed();
@@ -1,285 +0,0 @@
1
- -- ============================================================================
2
- -- 150_the_app_is_configured_in_the_database.sql — what a tenant HAS stops being
3
- -- a file in a repository (#280, ADR 0023).
4
- --
5
- -- The split the decision fixes: the MANIFEST is what a plugin CAN do — routes,
6
- -- screens, the permissions it declares, the species it registers. The DATABASE
7
- -- is what THIS tenant HAS — which plugins are on, which parts of them, how the
8
- -- menu is composed, what the branding is. Those were the same thing, living in
9
- -- `app.manifest.json` next to the code, which is why eleven applications needed
10
- -- eleven repositories and why turning a feature on meant a deploy.
11
- --
12
- -- WHAT WAS ALREADY HERE, and is not rebuilt: `tenants.settings` with the
13
- -- registry in `app.tenant_setting_keys` (076) already governs branding, locale
14
- -- and every other keyed setting, RPCs included. This file adds the two things
15
- -- that had no home at all:
16
- --
17
- -- §1 ACTIVATION — which plugins and which facets this tenant has. The type
18
- -- `TenantPluginBinding` has existed in @fayz-ai/core since the runtime was
19
- -- written, and nothing ever persisted it: activation lived in a file.
20
- -- §2 NAVIGATION — the tenant's own composition over what the plugins offer:
21
- -- order, hidden entries, renamed labels.
22
- --
23
- -- §3 is the one call that returns all of it, which is what makes the shell able
24
- -- to render from the database instead of from a bundle.
25
- --
26
- -- WHAT THIS FILE DOES NOT DECIDE. Whether the plan permits an activation is
27
- -- #281's question, and the column it will read (`source`) is already here so
28
- -- that answer does not need a second migration to change shape. Until then any
29
- -- activation is allowed and `source` records who did it.
30
- --
31
- -- Idempotent and replay-safe.
32
- -- ============================================================================
33
-
34
- -- ─────────────────────────────────────────────────────────────────────────
35
- -- §1 Activation — plugin and facet, per tenant
36
- --
37
- -- One table, not two: a facet is a part of a plugin, and (tenant, plugin, '')
38
- -- is the plugin itself. Two tables would have made "is this on?" a union in
39
- -- every reader.
40
- -- ─────────────────────────────────────────────────────────────────────────
41
-
42
- CREATE TABLE IF NOT EXISTS app.tenant_plugins (
43
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
44
- plugin_id text NOT NULL CHECK (plugin_id ~ '^[a-z][a-z0-9-]*$'),
45
- facet text NOT NULL DEFAULT '' CHECK (facet = '' OR facet ~ '^[a-z][a-z0-9_-]*$'),
46
- status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
47
- config jsonb NOT NULL DEFAULT '{}'::jsonb,
48
- source text NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'plan', 'trial', 'agent', 'default')),
49
- expires_at timestamptz,
50
- activated_by uuid,
51
- created_at timestamptz NOT NULL DEFAULT now(),
52
- updated_at timestamptz NOT NULL DEFAULT now(),
53
- PRIMARY KEY (tenant_id, plugin_id, facet)
54
- );
55
-
56
- COMMENT ON TABLE app.tenant_plugins IS
57
- 'What this tenant HAS (150, ADR 0023): which plugins are on, and which facets of them. The manifest says what a plugin CAN do; this says what is on here. facet = '''' is the plugin itself.';
58
- COMMENT ON COLUMN app.tenant_plugins.source IS
59
- 'Who turned it on: manual (an operator), plan (an entitlement), trial (with expires_at), agent (onboarding), default (the plugin ships defaultEnabled). #281 reads this to know what a plan change may switch off.';
60
- COMMENT ON COLUMN app.tenant_plugins.expires_at IS
61
- 'When a trial or courtesy ends. NULL is forever. Enforced by the reader — a row that has expired is not active, and nothing has to run to make that true.';
62
-
63
- CREATE INDEX IF NOT EXISTS tenant_plugins_active
64
- ON app.tenant_plugins (tenant_id)
65
- WHERE status = 'active';
66
-
67
- ALTER TABLE app.tenant_plugins ENABLE ROW LEVEL SECURITY;
68
- DROP POLICY IF EXISTS tenant_plugins_read ON app.tenant_plugins;
69
- CREATE POLICY tenant_plugins_read ON app.tenant_plugins
70
- FOR SELECT TO authenticated
71
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
72
- GRANT SELECT ON app.tenant_plugins TO authenticated;
73
- GRANT ALL ON app.tenant_plugins TO service_role;
74
-
75
- DROP TRIGGER IF EXISTS tenant_plugins_updated_at ON app.tenant_plugins;
76
- CREATE TRIGGER tenant_plugins_updated_at BEFORE UPDATE ON app.tenant_plugins
77
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
78
-
79
- -- ─────────────────────────────────────────────────────────────────────────
80
- -- §2 Navigation — the tenant's composition over what the plugins offer
81
- --
82
- -- Only the DIFFERENCE is stored. A plugin's nav entry is the default; a row
83
- -- here hides it, renames it or moves it. Storing the whole menu would mean a
84
- -- tenant stops receiving new entries the day a plugin ships one.
85
- -- ─────────────────────────────────────────────────────────────────────────
86
-
87
- CREATE TABLE IF NOT EXISTS app.tenant_nav (
88
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
89
- entry_key text NOT NULL,
90
- hidden boolean NOT NULL DEFAULT false,
91
- label text,
92
- section text CHECK (section IS NULL OR section IN ('main', 'secondary', 'settings')),
93
- position integer,
94
- created_at timestamptz NOT NULL DEFAULT now(),
95
- updated_at timestamptz NOT NULL DEFAULT now(),
96
- PRIMARY KEY (tenant_id, entry_key)
97
- );
98
-
99
- COMMENT ON TABLE app.tenant_nav IS
100
- 'The tenant''s own menu composition (150): only the difference from what the plugins offer. A plugin that ships a new entry reaches every tenant; a tenant that hid one keeps it hidden.';
101
- COMMENT ON COLUMN app.tenant_nav.entry_key IS
102
- 'The nav entry''s stable key — `<plugin>:<route>` as the shell composes it.';
103
-
104
- ALTER TABLE app.tenant_nav ENABLE ROW LEVEL SECURITY;
105
- DROP POLICY IF EXISTS tenant_nav_read ON app.tenant_nav;
106
- CREATE POLICY tenant_nav_read ON app.tenant_nav
107
- FOR SELECT TO authenticated
108
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
109
- GRANT SELECT ON app.tenant_nav TO authenticated;
110
- GRANT ALL ON app.tenant_nav TO service_role;
111
-
112
- DROP TRIGGER IF EXISTS tenant_nav_updated_at ON app.tenant_nav;
113
- CREATE TRIGGER tenant_nav_updated_at BEFORE UPDATE ON app.tenant_nav
114
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
115
-
116
- -- ─────────────────────────────────────────────────────────────────────────
117
- -- §3 Is it on? — one function, so no reader has to know about expiry
118
- -- ─────────────────────────────────────────────────────────────────────────
119
-
120
- CREATE OR REPLACE FUNCTION public.tenant_plugin_active(p_plugin text, p_facet text DEFAULT '')
121
- RETURNS boolean
122
- LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
123
- AS $$
124
- SELECT EXISTS (
125
- SELECT 1 FROM app.tenant_plugins t
126
- WHERE t.tenant_id = app.current_tenant_id()
127
- AND t.plugin_id = p_plugin
128
- AND t.facet = coalesce(p_facet, '')
129
- AND t.status = 'active'
130
- AND (t.expires_at IS NULL OR t.expires_at > now())
131
- )
132
- $$;
133
-
134
- COMMENT ON FUNCTION public.tenant_plugin_active(text, text) IS
135
- 'Whether this plugin (or one of its facets) is on for the current tenant, expiry included (150). One place, so a trial that lapsed cannot be on in one reader and off in another.';
136
-
137
- GRANT EXECUTE ON FUNCTION public.tenant_plugin_active(text, text) TO authenticated, service_role;
138
-
139
- -- ─────────────────────────────────────────────────────────────────────────
140
- -- §4 The single initial load
141
- --
142
- -- One call, one round trip, cacheable: everything the shell needs to render
143
- -- before it knows anything else. Branding comes from 076's RPC — which never
144
- -- raises, because branding must not be able to block a login.
145
- -- ─────────────────────────────────────────────────────────────────────────
146
-
147
- CREATE OR REPLACE FUNCTION public.app_config()
148
- RETURNS jsonb
149
- LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
150
- AS $$
151
- DECLARE
152
- v_tenant uuid := app.current_tenant_id();
153
- BEGIN
154
- IF v_tenant IS NULL THEN
155
- -- Fail closed and legibly: a caller with no resolvable tenant gets an empty
156
- -- configuration, not somebody else's.
157
- RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb);
158
- END IF;
159
-
160
- RETURN jsonb_build_object(
161
- 'tenant_id', v_tenant,
162
- 'branding', public.tenant_branding(),
163
- 'settings', public.tenant_settings(),
164
- 'plugins', coalesce((
165
- SELECT jsonb_agg(jsonb_build_object(
166
- 'plugin', t.plugin_id,
167
- 'facet', nullif(t.facet, ''),
168
- 'status', t.status,
169
- 'source', t.source,
170
- 'config', t.config,
171
- 'expires_at', t.expires_at
172
- ) ORDER BY t.plugin_id, t.facet)
173
- FROM app.tenant_plugins t
174
- WHERE t.tenant_id = v_tenant
175
- AND t.status = 'active'
176
- AND (t.expires_at IS NULL OR t.expires_at > now())
177
- ), '[]'::jsonb),
178
- 'nav', coalesce((
179
- SELECT jsonb_agg(jsonb_build_object(
180
- 'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
181
- 'section', n.section, 'position', n.position
182
- ) ORDER BY coalesce(n.position, 999), n.entry_key)
183
- FROM app.tenant_nav n
184
- WHERE n.tenant_id = v_tenant
185
- ), '[]'::jsonb),
186
- 'permissions', coalesce((
187
- SELECT jsonb_agg(DISTINCT rp.permission)
188
- FROM app.memberships m
189
- JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
190
- WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
191
- ), '[]'::jsonb),
192
- 'generated_at', now()
193
- );
194
- END $$;
195
-
196
- COMMENT ON FUNCTION public.app_config() IS
197
- 'Everything the shell needs to render, in one call (150, ADR 0023): branding, settings, active plugins and facets, the tenant''s nav composition and the caller''s permissions. Cacheable per (tenant, user); invalidated by any write through the RPCs below.';
198
-
199
- GRANT EXECUTE ON FUNCTION public.app_config() TO authenticated, service_role;
200
-
201
- -- ─────────────────────────────────────────────────────────────────────────
202
- -- §5 The write surface — scoped, permissioned, and the only door
203
- --
204
- -- The agent configures the system through this, never through raw SQL: it is
205
- -- bounded by the caller's tenant and by `settings.manage`, so an onboarding
206
- -- assistant cannot reach another tenant even if it tries.
207
- -- ─────────────────────────────────────────────────────────────────────────
208
-
209
- CREATE OR REPLACE FUNCTION public.tenant_plugin_set(
210
- p_plugin text,
211
- p_status text DEFAULT 'active',
212
- p_facet text DEFAULT '',
213
- p_config jsonb DEFAULT NULL,
214
- p_source text DEFAULT 'manual',
215
- p_expires timestamptz DEFAULT NULL
216
- ) RETURNS jsonb
217
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
218
- AS $$
219
- DECLARE
220
- v_tenant uuid := app.current_tenant_id();
221
- BEGIN
222
- IF v_tenant IS NULL THEN
223
- RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
224
- END IF;
225
- IF NOT app.has_permission('settings.manage') THEN
226
- RAISE EXCEPTION 'settings.manage is required to change what this account has'
227
- USING ERRCODE = '42501';
228
- END IF;
229
- IF p_status NOT IN ('active', 'disabled') THEN
230
- RAISE EXCEPTION 'status must be active or disabled' USING ERRCODE = '22023';
231
- END IF;
232
-
233
- INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, config, source, expires_at, activated_by)
234
- VALUES (v_tenant, p_plugin, coalesce(p_facet, ''), p_status, coalesce(p_config, '{}'::jsonb), p_source, p_expires, auth.uid())
235
- ON CONFLICT (tenant_id, plugin_id, facet) DO UPDATE
236
- SET status = EXCLUDED.status,
237
- config = coalesce(p_config, app.tenant_plugins.config),
238
- source = EXCLUDED.source,
239
- expires_at = EXCLUDED.expires_at,
240
- activated_by = EXCLUDED.activated_by;
241
-
242
- RETURN jsonb_build_object('plugin', p_plugin, 'facet', nullif(coalesce(p_facet, ''), ''), 'status', p_status);
243
- END $$;
244
-
245
- COMMENT ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) IS
246
- 'Turns a plugin or facet on or off for the CURRENT tenant (150). The only door: bounded by the caller''s tenant and by settings.manage, which is what lets the onboarding agent use it without being able to reach anyone else. Turning off changes what is seen, never what is stored.';
247
-
248
- REVOKE ALL ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) FROM public, anon;
249
- GRANT EXECUTE ON FUNCTION public.tenant_plugin_set(text, text, text, jsonb, text, timestamptz) TO authenticated, service_role;
250
-
251
- CREATE OR REPLACE FUNCTION public.tenant_nav_set(
252
- p_entry_key text,
253
- p_hidden boolean DEFAULT NULL,
254
- p_label text DEFAULT NULL,
255
- p_section text DEFAULT NULL,
256
- p_position integer DEFAULT NULL
257
- ) RETURNS jsonb
258
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
259
- AS $$
260
- DECLARE
261
- v_tenant uuid := app.current_tenant_id();
262
- BEGIN
263
- IF v_tenant IS NULL THEN
264
- RAISE EXCEPTION 'no tenant in scope' USING ERRCODE = '42501';
265
- END IF;
266
- IF NOT app.has_permission('settings.manage') THEN
267
- RAISE EXCEPTION 'settings.manage is required to change the menu' USING ERRCODE = '42501';
268
- END IF;
269
-
270
- INSERT INTO app.tenant_nav (tenant_id, entry_key, hidden, label, section, position)
271
- VALUES (v_tenant, p_entry_key, coalesce(p_hidden, false), p_label, p_section, p_position)
272
- ON CONFLICT (tenant_id, entry_key) DO UPDATE
273
- SET hidden = coalesce(p_hidden, app.tenant_nav.hidden),
274
- label = coalesce(p_label, app.tenant_nav.label),
275
- section = coalesce(p_section, app.tenant_nav.section),
276
- position = coalesce(p_position, app.tenant_nav.position);
277
-
278
- RETURN jsonb_build_object('entry', p_entry_key);
279
- END $$;
280
-
281
- COMMENT ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) IS
282
- 'Hides, renames or moves one menu entry for the current tenant (150). Only the difference is stored, so a plugin that ships a new entry still reaches everyone.';
283
-
284
- REVOKE ALL ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) FROM public, anon;
285
- GRANT EXECUTE ON FUNCTION public.tenant_nav_set(text, boolean, text, text, integer) TO authenticated, service_role;