@fayz-ai/db 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/canonical-schema.json +49 -0
  2. package/migrations/010_migration_ledger.sql +11 -1
  3. package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
  4. package/migrations/038_onboarding_responses.sql +103 -0
  5. package/migrations/039_unit_tree.sql +270 -0
  6. package/migrations/040_resource_grants.sql +474 -0
  7. package/migrations/041_scoped_columns.sql +192 -0
  8. package/migrations/042_unit_scope_policies.sql +145 -0
  9. package/migrations/043_view_invoker.sql +90 -0
  10. package/migrations/044_unit_member_facts.sql +47 -0
  11. package/migrations/045_unit_entry.sql +236 -0
  12. package/migrations/046_membership_visible_to_members.sql +85 -0
  13. package/migrations/047_tasks.sql +266 -0
  14. package/migrations/048_every_login_is_a_person.sql +190 -0
  15. package/migrations/049_bookable_people.sql +126 -0
  16. package/migrations/050_authz_core.sql +230 -0
  17. package/migrations/051_authz_helpers.sql +330 -0
  18. package/migrations/052_authz_fanout.sql +253 -0
  19. package/migrations/053_authz_catalog_templates.sql +402 -0
  20. package/migrations/060_tenancy_seam.sql +132 -0
  21. package/migrations/061_create_tenant.sql +125 -0
  22. package/migrations/062_invitations.sql +329 -0
  23. package/migrations/063_legacy_tenancy_retired.sql +568 -0
  24. package/migrations/064_agent_guard_authz.sql +118 -0
  25. package/migrations/064b_supersede_unit_scope.sql +205 -0
  26. package/migrations/065_scaffold_sweep.sql +139 -0
  27. package/migrations/066_shadow_report.sql +122 -0
  28. package/migrations/070_permission_catalog.sql +205 -0
  29. package/migrations/071_v1_permission_map.sql +266 -0
  30. package/migrations/072_role_rpcs.sql +126 -0
  31. package/migrations/073_legacy_rbac_retire.sql +471 -0
  32. package/migrations/075_company_legal_entities.sql +169 -0
  33. package/migrations/076_tenant_settings.sql +251 -0
  34. package/migrations/080_people_roles.sql +269 -0
  35. package/migrations/081_people_identity.sql +271 -0
  36. package/migrations/082_people_membership_link.sql +142 -0
  37. package/migrations/083_people_data_migration.sql +75 -0
  38. package/migrations/084_people_registrations_tokens.sql +282 -0
  39. package/migrations/085_catalog_products.sql +333 -0
  40. package/migrations/086_catalog_pricing.sql +194 -0
  41. package/migrations/087_catalog_packages.sql +134 -0
  42. package/migrations/090_orders_venda.sql +166 -0
  43. package/migrations/091_orders_scaffold.sql +92 -0
  44. package/migrations/095_config_field_rules.sql +261 -0
  45. package/migrations/096_audit_append_only.sql +256 -0
  46. package/migrations/097_sequences_authorized.sql +111 -0
  47. package/migrations/098_holidays.sql +80 -0
  48. package/migrations/099_access_grants.sql +293 -0
  49. package/migrations/100_migration_schema.sql +217 -0
  50. package/migrations/101_migration_allowlist.sql +202 -0
  51. package/migrations/102_migration_rpcs.sql +933 -0
  52. package/migrations/103_migration_reconcile_ponr.sql +132 -0
  53. package/migrations/105_policies_to_authenticated.sql +71 -0
  54. package/migrations/106_function_search_path.sql +55 -0
  55. package/migrations/107_core_table_prefix.sql +361 -0
  56. package/migrations/108_authz_write_guards.sql +149 -0
  57. package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
  58. package/migrations/110_people_kind_vocabulary.sql +120 -0
  59. package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
  60. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
  61. package/migrations/112_money_precision_standard.sql +132 -0
  62. package/migrations/113_addresses_units_companies.sql +114 -0
  63. package/migrations/114_migrations_ledger_immutable.sql +65 -0
  64. package/migrations/115_registration_honours_field_rules.sql +172 -0
  65. package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
  66. package/migrations/117_authz_reconciliation_schedule.sql +82 -0
  67. package/migrations/118_migration_catalog_writers.sql +450 -0
  68. package/migrations/119_migration_config_writers.sql +193 -0
  69. package/migrations/120_migration_facet_writer.sql +139 -0
  70. package/migrations/121_migration_financial_writers.sql +294 -0
  71. package/migrations/122_migration_inventory_writers.sql +245 -0
  72. package/migrations/123_migration_order_item_writer.sql +144 -0
  73. package/migrations/124_migration_ledger_writers.sql +170 -0
  74. package/migrations/125_migration_authz_people_writers.sql +205 -0
  75. package/migrations/126_owner_scoping_is_configuration.sql +247 -0
  76. package/migrations/127_scaffold_audit_hook.sql +98 -0
  77. package/migrations/128_shadow_tables_enforced.sql +93 -0
  78. package/migrations/129_order_kind_terminal_states.sql +57 -0
  79. package/migrations/130_every_login_is_a_person.sql +196 -0
  80. package/migrations/131_migration_invoice_split.sql +127 -0
  81. package/migrations/132_audit_prune_scheduled.sql +28 -0
  82. package/migrations/133_unit_is_a_session_property.sql +155 -0
  83. package/migrations/134_field_rules_on_read.sql +341 -0
  84. package/migrations/135_distribution_polarity.sql +158 -0
  85. package/migrations/136_unit_id_means_locality.sql +358 -0
  86. package/migrations/137_vendor_named_spine_objects.sql +126 -0
  87. package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
  88. package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
  89. package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
  90. package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
  91. package/migrations/142_a_person_has_addresses.sql +212 -0
  92. package/migrations/143_a_professional_has_a_rate.sql +75 -0
  93. package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
  94. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
  95. package/migrations/146_products_stock_has_one_writer.sql +67 -0
  96. package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
  97. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
  98. package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
  99. package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
  100. package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
  101. package/migrations/152_a_file_is_an_archetype.sql +222 -0
  102. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
  103. package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
  104. package/migrations/155_a_tenant_can_leave.sql +271 -0
  105. package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
  106. package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
  107. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
  108. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
  109. package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
  110. package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
  111. package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
  112. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
  113. package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
  114. package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
  115. package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
  116. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
  117. package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
  118. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
  119. package/package.json +6 -5
@@ -0,0 +1,49 @@
1
+ {
2
+ "$comment": "The canonical schema (#279, ADR 0022). ONE schema on every cluster: the spine plus every plugin that has passed the capability gate. The pool profiles in cli/pool-profiles/ stop deciding what a cluster HAS and become activation defaults — what a new tenant of that industry gets turned on, which is configuration and lives in the database (ADR 0023, migration 150). What varies per cluster is data, never DDL. A plugin joins `plugins` below when its gate is green; until then it is in `pending`, with the reason, and it exists in no cluster at all.",
3
+ "version": 1,
4
+ "plugins": [
5
+ {
6
+ "id": "financial",
7
+ "dir": "plugins/plugin-financial/src/migrations",
8
+ "gate": "capability",
9
+ "$note": "core-A. In three of the four live pools already."
10
+ },
11
+ {
12
+ "id": "inventory",
13
+ "dir": "plugins/plugin-inventory/src/migrations",
14
+ "gate": "capability",
15
+ "$note": "core-A. Sole writer of the stock archetype since #276."
16
+ },
17
+ {
18
+ "id": "tasks",
19
+ "dir": "plugins/plugin-tasks/src/migrations",
20
+ "gate": "enforced",
21
+ "$note": "The only plugin at ENFORCED — the reference implementation."
22
+ }
23
+ ],
24
+ "pending": [
25
+ { "id": "agenda", "reason": "Owns the species of the Compromisso archetype; the Google Calendar integration inside it has to come out as an addon first (#285's rule, ADR 0024)." },
26
+ { "id": "crm", "reason": "Installed in three pools; capability gate not green (seed + capability tests missing)." },
27
+ { "id": "forms", "reason": "Creates public.documents, which the base owns since 152 — the guarded CREATE has to go (#282)." },
28
+ { "id": "marketing", "reason": "Capability gate not green." },
29
+ { "id": "menu", "reason": "Nine tables of its own and no capability suite yet." },
30
+ { "id": "shop", "reason": "Its data lives in packages/shop, not in the plugin, and it still keeps a second stock book (#276's remaining half)." },
31
+ { "id": "courses", "reason": "Its data lives in packages/courses, not in the plugin (#285)." },
32
+ { "id": "conversations", "reason": "Capability gate not green." },
33
+ { "id": "automations", "reason": "Creates public.event_log, which the base owns; the copy is a guarded no-op that should go." },
34
+ { "id": "blog", "reason": "Capability gate not green." },
35
+ { "id": "reputation", "reason": "Capability gate not green." },
36
+ { "id": "scribe", "reason": "Capability gate not green." },
37
+ { "id": "banking-br", "reason": "Addon of financial; capability gate not green." },
38
+ { "id": "admin", "reason": "No data of its own — the base absorbed what it was for (#274 rule 1)." },
39
+ { "id": "auth", "reason": "No data of its own — same." },
40
+ { "id": "orders", "reason": "The Venda is a base archetype; nothing is left for a plugin to own." },
41
+ { "id": "dashboard", "reason": "A mechanism other plugins feed; no capability suite yet." },
42
+ { "id": "reports", "reason": "Same." },
43
+ { "id": "payments", "reason": "Never built." },
44
+ { "id": "sites", "reason": "Never built." },
45
+ { "id": "linkinbio", "reason": "Never built." },
46
+ { "id": "tables", "reason": "Never built; will own the restaurant floor when it is." },
47
+ { "id": "agent-runtime", "reason": "A library, not a plugin — it is base (rodada 3 Q1)." }
48
+ ]
49
+ }
@@ -33,4 +33,14 @@ ALTER TABLE public._migrations ENABLE ROW LEVEL SECURITY;
33
33
  -- service_role (which bypasses RLS anyway). No policies = no anon/authenticated access.
34
34
  REVOKE ALL ON public._migrations FROM anon, authenticated;
35
35
  GRANT ALL ON public._migrations TO service_role;
36
- GRANT USAGE, SELECT ON SEQUENCE public._migrations_id_seq TO service_role;
36
+ -- The sequence, WHATEVER IT IS CALLED. A legacy pool whose table was renamed
37
+ -- ahead of 012 still owns fayz_migration_ledger_id_seq, and naming the new one
38
+ -- here would stop the chain twelve files before the migration that renames it.
39
+ -- pg_get_serial_sequence asks the column instead of guessing.
40
+ DO $$
41
+ DECLARE v_seq text := pg_get_serial_sequence('public._migrations', 'id');
42
+ BEGIN
43
+ IF v_seq IS NOT NULL THEN
44
+ EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO service_role', v_seq);
45
+ END IF;
46
+ END $$;
@@ -0,0 +1,256 @@
1
+ -- ============================================================================
2
+ -- 037_sync_tick_one_at_a_time.sql — a connection with a dispatch in flight is
3
+ -- not due.
4
+ --
5
+ -- 033 selects what to dispatch by ONE test: `next_attempt_at <= now()`. It never
6
+ -- asks whether the previous dispatch came back. In the ordinary case that is
7
+ -- invisible — the advance pushes the row a whole interval into the future in the
8
+ -- same statement that dispatches it, so nothing is due again for an hour.
9
+ --
10
+ -- It stops being invisible the moment anything else writes `next_attempt_at`.
11
+ -- The shop's wake trigger (packages/shop 0064) sets it to `now()` whenever an
12
+ -- order, a customer or a product changes — which is exactly what a catalogue
13
+ -- pull DOES, by the thousand, while it is still running. Measured on the
14
+ -- ecommerce pool: bling-sync dispatched, pulled, wrote products, each write
15
+ -- moved its own next attempt to now(), the next tick dispatched it again on top
16
+ -- of itself, and the merchant's history filled with an hourly connector running
17
+ -- every sixty seconds. The cadence was never wrong; nothing enforced it.
18
+ --
19
+ -- ── The guard ────────────────────────────────────────────────────────────────
20
+ -- `last_run_id IS NOT NULL` already means "dispatched, no verdict yet" — the
21
+ -- fold and the reaper both key off it. This file makes the selection honour the
22
+ -- same fact: one dispatch per connection per kind at a time, and the reaper
23
+ -- (timeout_seconds) is what ends it if a function never answers. A wake that
24
+ -- lands mid-run is not lost, it is served by the tick after the verdict.
25
+ --
26
+ -- This is a ceiling nobody could opt out of, which is the point: rate limits,
27
+ -- the provider's daily quota and the connector's own cursor all assume its
28
+ -- previous call finished. `max_per_tick` bounds a connector across connections;
29
+ -- nothing bounded ONE connection against itself.
30
+ --
31
+ -- ── And the orphan ───────────────────────────────────────────────────────────
32
+ -- A guard on a pointer needs the pointer to be honest. If the run a schedule
33
+ -- points at no longer exists, the fold cannot fold it and the reaper cannot reap
34
+ -- it, and under the new guard that connection would never be dispatched again.
35
+ -- Cleared before the fold, so a vanished run costs one tick instead of forever.
36
+ --
37
+ -- CREATE OR REPLACE of plg_sync_tick, and nothing else: no table, no grant, no
38
+ -- cron job changes. Idempotent, and safe to re-run — `fayz db apply` re-applies
39
+ -- a file whose checksum moved.
40
+ -- ============================================================================
41
+
42
+ CREATE OR REPLACE FUNCTION public.plg_sync_tick(p_kind text)
43
+ RETURNS integer
44
+ LANGUAGE plpgsql
45
+ SECURITY DEFINER
46
+ SET search_path = public, extensions, net, vault
47
+ AS $fn$
48
+ DECLARE
49
+ v_base text;
50
+ v_secret text;
51
+ v_have_net boolean;
52
+ v_row record;
53
+ v_run_id uuid;
54
+ v_next timestamptz;
55
+ v_expires timestamptz;
56
+ v_offset integer;
57
+ v_dispatched integer := 0;
58
+ v_reason text;
59
+ BEGIN
60
+ IF p_kind NOT IN ('reconcile', 'renew') THEN
61
+ RAISE EXCEPTION 'plg_sync_tick: unknown kind %', p_kind;
62
+ END IF;
63
+
64
+ IF NOT pg_try_advisory_xact_lock(hashtext('plg_sync_tick:' || p_kind)) THEN
65
+ RETURN 0;
66
+ END IF;
67
+
68
+ v_have_net := to_regprocedure('net.http_post(text, jsonb, jsonb, jsonb, integer)') IS NOT NULL;
69
+
70
+ BEGIN
71
+ SELECT decrypted_secret INTO v_base
72
+ FROM vault.decrypted_secrets WHERE name = 'fayz_functions_url'
73
+ ORDER BY created_at DESC LIMIT 1;
74
+ SELECT decrypted_secret INTO v_secret
75
+ FROM vault.decrypted_secrets WHERE name = 'fayz_scheduler_secret'
76
+ ORDER BY created_at DESC LIMIT 1;
77
+ EXCEPTION WHEN OTHERS THEN
78
+ v_base := NULL;
79
+ v_secret := NULL;
80
+ END;
81
+
82
+ -- ── orphan ────────────────────────────────────────────────────────────────
83
+ -- Before anything reads `last_run_id` as "in flight": a pointer at a run that
84
+ -- no longer exists is not a dispatch in flight, it is a dead end that would
85
+ -- park this connection permanently.
86
+ UPDATE public.plg_sync_schedule s
87
+ SET last_run_id = NULL
88
+ WHERE s.kind = p_kind
89
+ AND s.last_run_id IS NOT NULL
90
+ AND NOT EXISTS (SELECT 1 FROM public.plg_sync_runs r WHERE r.id = s.last_run_id);
91
+
92
+ -- ── reap ──────────────────────────────────────────────────────────────────
93
+ UPDATE public.plg_sync_runs r
94
+ SET status = 'error',
95
+ finished_at = now(),
96
+ error = COALESCE(r.error, 'the connector function returned no verdict within the schedule timeout')
97
+ FROM public.plg_sync_schedule s
98
+ JOIN public.plg_connections c ON c.id = s.connection_id
99
+ JOIN public.plg_connector_schedules d
100
+ ON d.connector_id = c.connector_id AND d.kind = s.kind
101
+ WHERE s.kind = p_kind
102
+ AND r.id = s.last_run_id
103
+ AND r.status = 'running'
104
+ AND r.started_at < now() - make_interval(secs => d.timeout_seconds);
105
+
106
+ -- ── fold ──────────────────────────────────────────────────────────────────
107
+ UPDATE public.plg_sync_schedule s
108
+ SET failures = CASE WHEN r.status IN ('success', 'partial') THEN 0 ELSE s.failures + 1 END,
109
+ last_run_id = NULL,
110
+ next_attempt_at = CASE
111
+ WHEN r.status IN ('success', 'partial') THEN s.next_attempt_at
112
+ ELSE GREATEST(
113
+ s.next_attempt_at,
114
+ now() + make_interval(secs => LEAST(
115
+ d.interval_seconds::bigint * (2 ^ LEAST(s.failures + 1, 6))::bigint,
116
+ 86400)::double precision)
117
+ )
118
+ END
119
+ FROM public.plg_sync_runs r,
120
+ public.plg_connections c,
121
+ public.plg_connector_schedules d
122
+ WHERE s.kind = p_kind
123
+ AND r.id = s.last_run_id
124
+ AND r.finished_at IS NOT NULL
125
+ AND c.id = s.connection_id
126
+ AND d.connector_id = c.connector_id
127
+ AND d.kind = s.kind;
128
+
129
+ -- ── enroll ────────────────────────────────────────────────────────────────
130
+ INSERT INTO public.plg_sync_schedule (connection_id, kind, next_attempt_at)
131
+ SELECT q.id, p_kind,
132
+ now() + make_interval(secs => mod(abs(hashtext(q.id::text)::bigint), GREATEST(q.jitter_seconds, 1))::double precision)
133
+ FROM (
134
+ SELECT c.id, d.jitter_seconds
135
+ FROM public.plg_connections c
136
+ JOIN public.plg_connector_schedules d
137
+ ON d.connector_id = c.connector_id AND d.kind = p_kind AND d.enabled
138
+ LEFT JOIN public.plg_sync_schedule s
139
+ ON s.connection_id = c.id AND s.kind = p_kind
140
+ WHERE s.connection_id IS NULL
141
+ AND c.active
142
+ LIMIT 500
143
+ ) q
144
+ ON CONFLICT (connection_id, kind) DO NOTHING;
145
+
146
+ -- ── dispatch ──────────────────────────────────────────────────────────────
147
+ FOR v_row IN
148
+ SELECT *
149
+ FROM (
150
+ SELECT s.connection_id, s.failures, c.tenant_id, c.connector_id, c.provider_state,
151
+ d.function_name, d.action, d.interval_seconds, d.jitter_seconds,
152
+ d.floor_seconds, d.lead_seconds, d.timeout_seconds, d.max_per_tick,
153
+ row_number() OVER (PARTITION BY c.connector_id ORDER BY s.next_attempt_at, s.connection_id) AS rn
154
+ FROM (
155
+ SELECT s2.*
156
+ FROM public.plg_sync_schedule s2
157
+ WHERE s2.kind = p_kind AND s2.next_attempt_at <= now()
158
+ -- THE GUARD. Due is not enough: a connection whose last dispatch
159
+ -- has no verdict is already being served. Cleared by the fold on
160
+ -- a verdict, or by the reaper at timeout_seconds.
161
+ AND s2.last_run_id IS NULL
162
+ ORDER BY s2.next_attempt_at
163
+ LIMIT 2000
164
+ ) s
165
+ JOIN public.plg_connections c ON c.id = s.connection_id
166
+ JOIN public.plg_connector_schedules d
167
+ ON d.connector_id = c.connector_id AND d.kind = s.kind
168
+ WHERE d.enabled
169
+ AND c.active
170
+ AND c.status IN ('connected', 'error')
171
+ ) ranked
172
+ WHERE ranked.rn <= ranked.max_per_tick
173
+ LOOP
174
+ INSERT INTO public.plg_sync_runs
175
+ (tenant_id, connection_id, connector_id, direction, trigger_kind, status, stats)
176
+ VALUES
177
+ (v_row.tenant_id, v_row.connection_id, v_row.connector_id,
178
+ CASE WHEN p_kind = 'renew' THEN 'outbound' ELSE 'inbound' END,
179
+ 'scheduled', 'running',
180
+ jsonb_build_object('schedule_kind', p_kind, 'attempt', v_row.failures + 1))
181
+ RETURNING id INTO v_run_id;
182
+
183
+ v_reason := NULL;
184
+ IF NOT v_have_net THEN
185
+ v_reason := 'pg_net is not installed on this pool, so the scheduler cannot call the connector function';
186
+ ELSIF COALESCE(v_base, '') = '' OR COALESCE(v_secret, '') = '' THEN
187
+ v_reason := 'the scheduler endpoint is not configured: run plg_set_scheduler_endpoint on this pool';
188
+ ELSE
189
+ BEGIN
190
+ PERFORM net.http_post(
191
+ url := v_base || '/' || v_row.function_name,
192
+ headers := jsonb_build_object(
193
+ 'Content-Type', 'application/json',
194
+ 'Authorization', 'Bearer ' || v_secret,
195
+ 'X-Fayz-Scheduler', '1'
196
+ ),
197
+ body := jsonb_build_object(
198
+ 'action', v_row.action,
199
+ 'kind', p_kind,
200
+ 'connectionId', v_row.connection_id,
201
+ 'tenantId', v_row.tenant_id,
202
+ 'connectorId', v_row.connector_id,
203
+ 'runId', v_run_id,
204
+ 'attempt', v_row.failures + 1,
205
+ 'scheduledAt', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
206
+ ),
207
+ timeout_milliseconds := v_row.timeout_seconds * 1000
208
+ );
209
+ EXCEPTION WHEN OTHERS THEN
210
+ v_reason := 'the scheduler could not post to the connector function: ' || SQLERRM;
211
+ END;
212
+ END IF;
213
+
214
+ IF v_reason IS NOT NULL THEN
215
+ UPDATE public.plg_sync_runs
216
+ SET status = 'error', error = v_reason, finished_at = now()
217
+ WHERE id = v_run_id;
218
+ END IF;
219
+
220
+ -- ── advance ─────────────────────────────────────────────────────────────
221
+ v_offset := mod(abs(hashtext(v_row.connection_id::text)::bigint),
222
+ GREATEST(v_row.jitter_seconds, 1))::integer;
223
+ v_next := now() + make_interval(secs => (v_row.interval_seconds + v_offset)::double precision);
224
+
225
+ IF p_kind = 'renew' THEN
226
+ BEGIN
227
+ v_expires := (v_row.provider_state ->> 'expiresAt')::timestamptz;
228
+ EXCEPTION WHEN OTHERS THEN
229
+ v_expires := NULL;
230
+ END;
231
+ IF v_expires IS NOT NULL THEN
232
+ v_next := LEAST(v_next, v_expires - make_interval(secs => v_row.lead_seconds::double precision));
233
+ END IF;
234
+ END IF;
235
+
236
+ v_next := GREATEST(v_next, now() + make_interval(secs => v_row.floor_seconds::double precision));
237
+
238
+ UPDATE public.plg_sync_schedule
239
+ SET next_attempt_at = v_next,
240
+ last_attempt_at = now(),
241
+ last_run_id = v_run_id
242
+ WHERE connection_id = v_row.connection_id AND kind = p_kind;
243
+
244
+ v_dispatched := v_dispatched + 1;
245
+ END LOOP;
246
+
247
+ RETURN v_dispatched;
248
+ END;
249
+ $fn$;
250
+
251
+ COMMENT ON FUNCTION public.plg_sync_tick(text) IS
252
+ 'One tick of the sync clock: clear orphaned pointers, reap dead dispatches, '
253
+ 'fold verdicts into backoff, enrol new connections with a deterministic '
254
+ 'offset, dispatch what is due AND has no dispatch in flight, up to '
255
+ 'max_per_tick per connector. Files a plg_sync_runs row for EVERY attempt, '
256
+ 'including one that never reached the function. service_role and cron only.';
@@ -0,0 +1,103 @@
1
+ -- ============================================================================
2
+ -- 038 — plg_onboarding_responses: what the owner told us while creating the
3
+ -- workspace, and tenant_slug_available: can this handle still be taken.
4
+ --
5
+ -- The setup wizard asks things no table has a column for — whether they already
6
+ -- run another system, how many chairs, how they hear about us — and the answers
7
+ -- are worth keeping BECAUSE they are not settings: they are how we decide what
8
+ -- to configure for this merchant and what kind of merchant they are.
9
+ --
10
+ -- The answers live in ONE jsonb keyed by question id, not in typed columns. That
11
+ -- is the point: the questionnaire gets reworded every time we learn something
12
+ -- about a vertical, and rewording must never be a migration. The two things we
13
+ -- will actually filter and group by — the vertical and where they came from —
14
+ -- are lifted out as columns, because those are the queries we know we want.
15
+ --
16
+ -- One row per completed run. Nothing is written before the tenant exists: the
17
+ -- wizard holds its draft in localStorage until the org is created, so a fully
18
+ -- abandoned flow leaves no orphan rows to reconcile.
19
+ --
20
+ -- Idempotent.
21
+ -- ============================================================================
22
+
23
+ CREATE TABLE IF NOT EXISTS public.plg_onboarding_responses (
24
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
25
+
26
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
27
+ -- Who answered. Not the same as the tenant owner forever — an owner can be
28
+ -- transferred, and it stays true that THIS account filled the form.
29
+ user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
30
+
31
+ -- Which questionnaire, and which revision of it. Answers from flow_version 1
32
+ -- are not comparable to version 3 once questions are dropped or rescaled, and
33
+ -- the stamp is what lets a report say so instead of silently averaging both.
34
+ flow_id text NOT NULL,
35
+ flow_version integer NOT NULL DEFAULT 1,
36
+
37
+ -- Copied from the tenant at answer time on purpose: a merchant who later
38
+ -- switches vertical must not retro-relabel the answers they gave as a salon.
39
+ vertical_id text,
40
+ -- Where the flow was opened from: first workspace after signup, or an extra
41
+ -- one from the switcher. Same questions, very different intent.
42
+ source text CHECK (source IS NULL OR source IN ('signup', 'switcher')),
43
+ -- Lifted out of `answers` because acquisition is the one question we will
44
+ -- group by across every tenant, and reaching into jsonb for it every time is
45
+ -- how a "how did they find us" report ends up not being written.
46
+ heard_from text,
47
+
48
+ -- { "<question id>": <answer> }. Scalars for single choice, arrays for multi.
49
+ answers jsonb NOT NULL DEFAULT '{}'::jsonb,
50
+
51
+ created_at timestamptz NOT NULL DEFAULT now()
52
+ );
53
+
54
+ -- The tenant's own answers, newest first — the read the admin does.
55
+ CREATE INDEX IF NOT EXISTS plg_onboarding_responses_tenant
56
+ ON public.plg_onboarding_responses (tenant_id, created_at DESC);
57
+ -- The cross-tenant read: "what did restaurants say", "where did they come from".
58
+ CREATE INDEX IF NOT EXISTS plg_onboarding_responses_segment
59
+ ON public.plg_onboarding_responses (vertical_id, heard_from);
60
+
61
+ -- ---------------------------------------------------------------------------
62
+ -- RLS. 011 closed `anon` on new public tables but not `authenticated`, which is
63
+ -- still born with the full grant — here that would be every tenant reading every
64
+ -- other tenant's answers. Stripped first, granted back deliberately.
65
+ -- ---------------------------------------------------------------------------
66
+ REVOKE ALL ON public.plg_onboarding_responses FROM anon, authenticated;
67
+
68
+ ALTER TABLE public.plg_onboarding_responses ENABLE ROW LEVEL SECURITY;
69
+
70
+ -- No UPDATE, no DELETE: this is a record of what someone said at a moment, and
71
+ -- an answer that can be rewritten later is not evidence of anything.
72
+ GRANT SELECT, INSERT ON public.plg_onboarding_responses TO authenticated;
73
+
74
+ DROP POLICY IF EXISTS plg_onboarding_responses_member_read ON public.plg_onboarding_responses;
75
+ CREATE POLICY plg_onboarding_responses_member_read ON public.plg_onboarding_responses
76
+ FOR SELECT TO authenticated
77
+ USING (tenant_id IN (SELECT public.user_tenant_ids()));
78
+
79
+ DROP POLICY IF EXISTS plg_onboarding_responses_member_insert ON public.plg_onboarding_responses;
80
+ CREATE POLICY plg_onboarding_responses_member_insert ON public.plg_onboarding_responses
81
+ FOR INSERT TO authenticated
82
+ WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()) AND user_id = auth.uid());
83
+
84
+ -- ---------------------------------------------------------------------------
85
+ -- tenant_slug_available — is this handle free?
86
+ --
87
+ -- `tenants.slug` is UNIQUE, and RLS keeps a signed-in user from reading tenants
88
+ -- they do not belong to. Without this the wizard cannot tell someone their
89
+ -- handle is taken until the final submit fails, four questions after they chose
90
+ -- it. SECURITY DEFINER, returning only a boolean: it answers "is it free", never
91
+ -- "who has it".
92
+ -- ---------------------------------------------------------------------------
93
+ CREATE OR REPLACE FUNCTION public.tenant_slug_available(p_slug text)
94
+ RETURNS boolean
95
+ LANGUAGE sql STABLE SECURITY DEFINER
96
+ SET search_path = public, pg_temp
97
+ AS $$
98
+ SELECT p_slug ~ '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$'
99
+ AND NOT EXISTS (SELECT 1 FROM public.tenants t WHERE t.slug = lower(p_slug));
100
+ $$;
101
+
102
+ REVOKE ALL ON FUNCTION public.tenant_slug_available(text) FROM PUBLIC, anon;
103
+ GRANT EXECUTE ON FUNCTION public.tenant_slug_available(text) TO authenticated, service_role;