@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,184 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 163_a_species_nobody_declared_goes_to_quarantine.sql — the half of #274 that
3
+ -- 147 could only refuse (#274, ADR 0017).
4
+ --
5
+ -- 147's rule is that a V1 row whose species nobody declared goes to quarantine
6
+ -- and NEVER weakens the constraint. It kept the second half absolutely: a value
7
+ -- outside the identifier format raises, and the FK it is about to create is not
8
+ -- softened to accommodate it. What it could not do is the first half — moving
9
+ -- the row — because at that point in the file there is nowhere to move it to.
10
+ --
11
+ -- This is that half, and it is deliberately non-destructive. Three things
12
+ -- happen to a row whose species has no owner:
13
+ --
14
+ -- 1. the row is RECORDED, whole, in `migration.kind_quarantine` — so the
15
+ -- original value survives even after the column is repaired;
16
+ -- 2. the column is repointed at `unclassified`, a species the base declares
17
+ -- for exactly this, so the foreign key holds;
18
+ -- 3. nothing is deleted. Not the row, not the value, not the history.
19
+ --
20
+ -- ── Why `unclassified` and not NULL ────────────────────────────────────────
21
+ -- `kind` is NOT NULL on every archetype, and it should be: a Compromisso with
22
+ -- no species is a row nothing can render. A declared species meaning "nobody
23
+ -- has said what this is" is honest, queryable, and impossible to mistake for a
24
+ -- real classification — which is what a silently-invented species would be.
25
+ --
26
+ -- ── Why this cannot fix a pool that 147 already refused ────────────────────
27
+ -- 147 runs first and raises before this file exists. That ordering is correct:
28
+ -- refusing is safer than repairing something nobody has looked at. The runbook
29
+ -- for a pool in that state is to apply up to 146, run the body of §2 by hand,
30
+ -- then continue — which is why §2 is a FUNCTION rather than a loose DO block.
31
+ -- ---------------------------------------------------------------------------
32
+
33
+ -- ─────────────────────────────────────────────────────────────────────────
34
+ -- §1 Where an undeclared species goes
35
+ -- ─────────────────────────────────────────────────────────────────────────
36
+
37
+ CREATE SCHEMA IF NOT EXISTS migration;
38
+
39
+ CREATE TABLE IF NOT EXISTS migration.kind_quarantine (
40
+ id bigserial PRIMARY KEY,
41
+ tenant_id uuid,
42
+ archetype text NOT NULL,
43
+ table_name text NOT NULL,
44
+ column_name text NOT NULL,
45
+ row_id uuid,
46
+ -- The value as it was, before the column was repaired. This is the whole
47
+ -- point: a species is a business word, and the word is the evidence.
48
+ original_kind text NOT NULL,
49
+ payload jsonb,
50
+ reason text NOT NULL,
51
+ created_at timestamptz NOT NULL DEFAULT now(),
52
+ resolved_at timestamptz,
53
+ resolution text
54
+ );
55
+
56
+ COMMENT ON TABLE migration.kind_quarantine IS
57
+ 'Rows whose species no plugin declared (163, #274). The original value is kept here and the live column is repointed at `unclassified`, so the foreign key holds without anything being deleted or any constraint being weakened. A human registers the species and re-points the rows.';
58
+
59
+ CREATE INDEX IF NOT EXISTS kind_quarantine_open_idx
60
+ ON migration.kind_quarantine (archetype, original_kind) WHERE resolved_at IS NULL;
61
+
62
+ -- The migration schema's contract: RLS on every table, no policies, so
63
+ -- PostgREST is deny-all and only the service role (which bypasses RLS) reaches
64
+ -- it. Asserted by 090_migration_ledger/001.
65
+ ALTER TABLE migration.kind_quarantine ENABLE ROW LEVEL SECURITY;
66
+ REVOKE ALL ON migration.kind_quarantine FROM public, anon, authenticated;
67
+ GRANT ALL ON migration.kind_quarantine TO service_role;
68
+ GRANT USAGE, SELECT ON SEQUENCE migration.kind_quarantine_id_seq TO service_role;
69
+
70
+ -- The species that means "nobody has said what this is". Declared on every
71
+ -- archetype so the repair has somewhere to point, whatever the table.
72
+ DO $$
73
+ DECLARE
74
+ v_archetype text;
75
+ BEGIN
76
+ IF to_regprocedure('public.register_kind(text, text, text, text[], jsonb, text)') IS NULL THEN
77
+ RETURN;
78
+ END IF;
79
+ FOREACH v_archetype IN ARRAY ARRAY['person', 'person_role', 'category', 'appointment', 'schedule', 'order', 'document']
80
+ LOOP
81
+ BEGIN
82
+ PERFORM public.register_kind(
83
+ v_archetype, 'unclassified', 'base', NULL, NULL,
84
+ 'Nobody has declared what this row is. Set by 163 when a V1 value had no owner — the original is in migration.kind_quarantine, and this is not a classification, it is the absence of one.');
85
+ EXCEPTION WHEN OTHERS THEN
86
+ RAISE NOTICE '163: could not declare unclassified for % (%)', v_archetype, SQLERRM;
87
+ END;
88
+ END LOOP;
89
+ END $$;
90
+
91
+ -- ─────────────────────────────────────────────────────────────────────────
92
+ -- §2 The sweep
93
+ -- ─────────────────────────────────────────────────────────────────────────
94
+
95
+ CREATE OR REPLACE FUNCTION public.quarantine_unregistered_kinds(p_dry_run boolean DEFAULT true)
96
+ RETURNS TABLE (archetype text, table_name text, original_kind text, rows_affected bigint, action text)
97
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
98
+ AS $$
99
+ DECLARE
100
+ v_pair record;
101
+ v_bad record;
102
+ v_registry text;
103
+ v_n bigint;
104
+ BEGIN
105
+ FOR v_pair IN SELECT * FROM (VALUES
106
+ ('person', 'people', 'kind'),
107
+ ('person_role', 'person_roles', 'role'),
108
+ ('category', 'categories', 'kind'),
109
+ ('appointment', 'appointments', 'kind'),
110
+ ('schedule', 'schedules', 'kind'),
111
+ ('order', 'orders', 'kind'),
112
+ ('document', 'documents', 'kind')
113
+ ) AS p(arch, tbl, col)
114
+ LOOP
115
+ IF to_regclass('public.' || quote_ident(v_pair.tbl)) IS NULL THEN CONTINUE; END IF;
116
+ v_registry := public.kind_registry_table(v_pair.arch);
117
+ IF v_registry IS NULL OR to_regclass('public.' || quote_ident(v_registry)) IS NULL THEN CONTINUE; END IF;
118
+
119
+ -- Every distinct value in the data that the registry does not know.
120
+ FOR v_bad IN EXECUTE format(
121
+ 'SELECT t.%I AS k, count(*)::bigint AS n FROM public.%I t
122
+ WHERE t.%I IS NOT NULL
123
+ AND NOT EXISTS (SELECT 1 FROM public.%I r WHERE r.kind = t.%I)
124
+ GROUP BY t.%I',
125
+ v_pair.col, v_pair.tbl, v_pair.col, v_registry, v_pair.col, v_pair.col)
126
+ LOOP
127
+ archetype := v_pair.arch;
128
+ table_name := v_pair.tbl;
129
+ original_kind := v_bad.k;
130
+ rows_affected := v_bad.n;
131
+
132
+ IF p_dry_run THEN
133
+ action := 'would quarantine';
134
+ RETURN NEXT;
135
+ CONTINUE;
136
+ END IF;
137
+
138
+ -- Record first, repair second. In that order, because a crash between
139
+ -- the two must lose the repair, never the evidence.
140
+ EXECUTE format(
141
+ 'INSERT INTO migration.kind_quarantine (tenant_id, archetype, table_name, column_name, row_id, original_kind, payload, reason)
142
+ SELECT t.tenant_id, %L, %L, %L, t.id, t.%I, to_jsonb(t),
143
+ ''No plugin declared this species. Repointed at unclassified by 163 so the FK holds - the word is kept here.''
144
+ FROM public.%I t WHERE t.%I = %L',
145
+ v_pair.arch, v_pair.tbl, v_pair.col, v_pair.col, v_pair.tbl, v_pair.col, v_bad.k);
146
+
147
+ EXECUTE format('UPDATE public.%I SET %I = ''unclassified'' WHERE %I = %L',
148
+ v_pair.tbl, v_pair.col, v_pair.col, v_bad.k);
149
+ GET DIAGNOSTICS v_n = ROW_COUNT;
150
+ rows_affected := v_n;
151
+ action := 'quarantined';
152
+ RETURN NEXT;
153
+ END LOOP;
154
+ END LOOP;
155
+ END $$;
156
+
157
+ COMMENT ON FUNCTION public.quarantine_unregistered_kinds(boolean) IS
158
+ 'Moves rows whose species nobody declared out of the way WITHOUT deleting anything (163, #274): the original value goes to migration.kind_quarantine and the column is repointed at `unclassified`. Dry run by default — an irreversible-looking sweep should have to be asked for twice.';
159
+
160
+ REVOKE ALL ON FUNCTION public.quarantine_unregistered_kinds(boolean) FROM public, anon, authenticated;
161
+ GRANT EXECUTE ON FUNCTION public.quarantine_unregistered_kinds(boolean) TO service_role;
162
+
163
+ -- ─────────────────────────────────────────────────────────────────────────
164
+ -- §3 The census, from the database rather than from CI
165
+ --
166
+ -- `scripts/check-kind-registry.mjs` reads the repository and answers "does the
167
+ -- code declare what the code writes". This answers the other question: does
168
+ -- anything in THIS pool still have no owner. 147 adopted every value it found
169
+ -- with owner `unattributed`, and that is the backlog.
170
+ -- ─────────────────────────────────────────────────────────────────────────
171
+
172
+ CREATE OR REPLACE VIEW public.v_species_without_owner
173
+ WITH (security_invoker = true) AS
174
+ SELECT 'person'::text AS archetype, k.kind, k.plugin, k.status FROM public.person_kinds k WHERE k.plugin = 'unattributed'
175
+ UNION ALL SELECT 'person_role', k.kind, k.plugin, k.status FROM public.person_role_kinds k WHERE k.plugin = 'unattributed'
176
+ UNION ALL SELECT 'category', k.kind, k.plugin, k.status FROM public.category_kinds k WHERE k.plugin = 'unattributed'
177
+ UNION ALL SELECT 'appointment', k.kind, k.plugin, k.status FROM public.appointment_kinds k WHERE k.plugin = 'unattributed'
178
+ UNION ALL SELECT 'schedule', k.kind, k.plugin, k.status FROM public.schedule_kinds k WHERE k.plugin = 'unattributed'
179
+ UNION ALL SELECT 'order', k.kind, k.plugin, k.status FROM public.order_kinds k WHERE k.plugin = 'unattributed';
180
+
181
+ COMMENT ON VIEW public.v_species_without_owner IS
182
+ 'Species present in this pool that 147 adopted with no owner (163, #274). Each row is a plugin that has not declared something it writes — the census that turns adoption into work rather than into a permanent state.';
183
+
184
+ GRANT SELECT ON public.v_species_without_owner TO authenticated, service_role;
@@ -0,0 +1,26 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 164_the_sequence_table_joins_the_register.sql — one more name whose creator
3
+ -- changed (#285, #283).
4
+ --
5
+ -- `public.sequences` — per-tenant document numbering — has been base-owned since
6
+ -- spine 097. plugin-financial created it in its own 004 and stopped, and the
7
+ -- assertion that replaced the CREATE is plugin-financial 037.
8
+ --
9
+ -- ── Why this is a new file and not a line added to 162 ─────────────────────
10
+ -- Because 162 had already been applied to a pool, and the ledger treats a
11
+ -- changed checksum on an applied file as a hard stop. I learned that here the
12
+ -- hard way, one migration after watching the same rule stop #285's edit to
13
+ -- plugin-financial 004 — which is the better argument for the rule than any
14
+ -- comment: it catches the person who wrote it, on the same afternoon, and it
15
+ -- catches them before a pool is left in a state nobody can describe.
16
+ --
17
+ -- The register is append-only in practice for exactly this reason. A name that
18
+ -- changes hands gets a new file, every time.
19
+ -- ---------------------------------------------------------------------------
20
+
21
+ INSERT INTO app.adopted_table_names (table_name, former_creator, new_owner, adopted_in, disposition, note) VALUES
22
+ ('sequences', 'financial', 'base', '097', 'adopted',
23
+ 'Per-tenant document numbering, base-owned since spine 097. plugin-financial created it in 004 and stopped — the assertion that replaced the CREATE is financial 037, a NEW file, because 004 had already run on every pool.')
24
+ ON CONFLICT (table_name) DO UPDATE
25
+ SET former_creator = EXCLUDED.former_creator, new_owner = EXCLUDED.new_owner,
26
+ adopted_in = EXCLUDED.adopted_in, disposition = EXCLUDED.disposition, note = EXCLUDED.note;
@@ -0,0 +1,90 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 165_a_view_is_not_a_way_in.sql — every view in `public` stops being born
3
+ -- writable by anon.
4
+ --
5
+ -- ── The hole, measured ─────────────────────────────────────────────────────
6
+ -- `ALTER DEFAULT PRIVILEGES` in this schema grants anon, authenticated and
7
+ -- service_role `arwdDxtm` on every table AND VIEW that postgres creates in
8
+ -- `public` — and migrations run as postgres. So a view is BORN with insert,
9
+ -- update and delete for an unauthenticated caller, whatever the table beneath
10
+ -- it grants, and a hand-written `GRANT SELECT` after the fact adds nothing
11
+ -- because the default already gave more.
12
+ --
13
+ -- On the ecommerce cluster, read the day this was written: **85 of 94 views in
14
+ -- `public` accept INSERT from `anon`.** Eleven tenants share that database.
15
+ --
16
+ -- Why it is not always exploitable is not a reason to leave it: a simple view
17
+ -- is auto-updatable, so a write through it reaches the table. Where the view
18
+ -- carries `security_invoker` the caller's own RLS still applies and anon is
19
+ -- usually stopped there — but a view WITHOUT invoker runs as its owner and RLS
20
+ -- does not apply at all. That combination is a straight bypass, and the only
21
+ -- thing standing between the two cases is which flag somebody remembered.
22
+ --
23
+ -- ── What this does ─────────────────────────────────────────────────────────
24
+ -- Revokes INSERT, UPDATE, DELETE and TRUNCATE from anon and authenticated on
25
+ -- every view in `public`. SELECT is left exactly as it was: reading is what a
26
+ -- view is for, and this file is not the place to relitigate who may read what.
27
+ -- service_role keeps everything — it bypasses RLS by design and is the runner.
28
+ --
29
+ -- A view that genuinely needs to be written through (an INSTEAD OF trigger, a
30
+ -- deliberate write surface) grants that back in its own migration, where the
31
+ -- decision is visible. None exists today.
32
+ --
33
+ -- Found by a parallel session reviewing 148's compat alias, which is the
34
+ -- narrow case; the census showed it was every view.
35
+ -- ---------------------------------------------------------------------------
36
+
37
+ DO $$
38
+ DECLARE
39
+ v_view record;
40
+ v_fixed integer := 0;
41
+ BEGIN
42
+ FOR v_view IN
43
+ SELECT c.relname
44
+ FROM pg_class c
45
+ JOIN pg_namespace n ON n.oid = c.relnamespace
46
+ WHERE n.nspname = 'public'
47
+ AND c.relkind IN ('v', 'm')
48
+ ORDER BY c.relname
49
+ LOOP
50
+ IF has_table_privilege('anon', format('public.%I', v_view.relname), 'INSERT')
51
+ OR has_table_privilege('anon', format('public.%I', v_view.relname), 'UPDATE')
52
+ OR has_table_privilege('anon', format('public.%I', v_view.relname), 'DELETE')
53
+ OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'INSERT')
54
+ OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'UPDATE')
55
+ OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'DELETE') THEN
56
+ EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.%I FROM anon, authenticated', v_view.relname);
57
+ v_fixed := v_fixed + 1;
58
+ END IF;
59
+ END LOOP;
60
+ RAISE NOTICE '165: closed the write grant on % view(s)', v_fixed;
61
+ END $$;
62
+
63
+ -- And stop the next one being born with it. The default privilege is what
64
+ -- created the hole, so narrowing the default is what stops it coming back on
65
+ -- the next view any migration creates.
66
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON TABLES FROM anon;
67
+
68
+ -- ── the census ─────────────────────────────────────────────────────────────
69
+
70
+ CREATE OR REPLACE VIEW public.v_views_anon_can_write
71
+ WITH (security_invoker = true) AS
72
+ SELECT c.relname::text AS view_name,
73
+ c.relkind = 'm' AS materialized,
74
+ -- A view without security_invoker runs as its OWNER, so RLS does not
75
+ -- apply to what it reads. Writable AND definer is the straight bypass.
76
+ NOT coalesce((SELECT option_value = 'true' FROM pg_options_to_table(c.reloptions)
77
+ WHERE option_name = 'security_invoker'), false) AS runs_as_owner
78
+ FROM pg_class c
79
+ JOIN pg_namespace n ON n.oid = c.relnamespace
80
+ WHERE n.nspname = 'public'
81
+ AND c.relkind IN ('v', 'm')
82
+ AND (has_table_privilege('anon', c.oid, 'INSERT')
83
+ OR has_table_privilege('anon', c.oid, 'UPDATE')
84
+ OR has_table_privilege('anon', c.oid, 'DELETE'))
85
+ ORDER BY c.relname;
86
+
87
+ COMMENT ON VIEW public.v_views_anon_can_write IS
88
+ 'Views an unauthenticated caller can write through (165). Zero is the expected reading. `runs_as_owner` true is the worse half: without security_invoker the view reads as its owner and RLS does not apply.';
89
+
90
+ GRANT SELECT ON public.v_views_anon_can_write TO authenticated, service_role;
@@ -0,0 +1,72 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 166_the_platform_billing_leftovers_go.sql — three Stripe-shaped tables that
3
+ -- were never the pool's business (#183).
4
+ --
5
+ -- `001_core.sql` created `invoices`, `payment_events` and `subscriptions` in the
6
+ -- Fayz Cloud era, when one database was meant to hold both moneys: what a
7
+ -- tenant's CUSTOMER pays the tenant, and what the TENANT pays Fayz. DECISIONS
8
+ -- 2026-07-24 split those — industry pools superseded Fayz Cloud, and platform
9
+ -- billing moved to the platform side.
10
+ --
11
+ -- What is left in the pool is the wrong half, and it costs more than the space:
12
+ -- these three compete for the word "invoice" with `plg_financial_invoices`,
13
+ -- which is exactly the confusion that made `plg_financial_titles` need renaming
14
+ -- in the first place. A reader who greps "invoice" in a pool should find one
15
+ -- answer.
16
+ --
17
+ -- ── Measured before dropping ───────────────────────────────────────────────
18
+ -- Zero rows on ecommerce and salon, the two largest pools. Zero readers in the
19
+ -- TypeScript: the only `'subscriptions'` matches are a nav-tab label in
20
+ -- plugin-courses, unrelated to this table.
21
+ --
22
+ -- ── `plans` STAYS ─────────────────────────────────────────────────────────
23
+ -- It is load-bearing and not a leftover: `agent_guard()` resolves role × plan ×
24
+ -- limits through `tenants.plan → plans.entitlements` in SQL, and 154's ceiling
25
+ -- reads `tenants.settings billing.plan_id`. Entitlements are enforced in the
26
+ -- database, so the catalogue belongs in the database.
27
+ --
28
+ -- ── Empty or nothing ──────────────────────────────────────────────────────
29
+ -- The drop is guarded on the table being EMPTY. A pool that turns out to hold
30
+ -- rows keeps them and says so — nobody's billing history is deleted to tidy a
31
+ -- schema, and finding rows here would mean the assumption above is wrong for
32
+ -- that pool, which is worth knowing rather than erasing.
33
+ -- ---------------------------------------------------------------------------
34
+
35
+ DO $$
36
+ DECLARE
37
+ v_table text;
38
+ v_rows bigint;
39
+ BEGIN
40
+ FOREACH v_table IN ARRAY ARRAY['payment_events', 'invoices', 'subscriptions']
41
+ LOOP
42
+ IF to_regclass('public.' || quote_ident(v_table)) IS NULL THEN CONTINUE; END IF;
43
+
44
+ EXECUTE format('SELECT count(*) FROM public.%I', v_table) INTO v_rows;
45
+ IF v_rows > 0 THEN
46
+ RAISE NOTICE '166: public.% holds % row(s) — kept. Platform billing was supposed to have moved off the pool; a row here says otherwise for this cluster.', v_table, v_rows;
47
+ CONTINUE;
48
+ END IF;
49
+
50
+ -- CASCADE only reaches views over an empty table, and any such view is
51
+ -- reporting on nothing. A dependent TABLE would be a foreign key, which
52
+ -- would have made the count non-zero on the other side.
53
+ EXECUTE format('DROP TABLE public.%I CASCADE', v_table);
54
+ RAISE NOTICE '166: dropped public.% — platform billing is the platform''s (DECISIONS 2026-07-24)', v_table;
55
+ END LOOP;
56
+ END $$;
57
+
58
+ -- What is left, for a pool where something was kept.
59
+ CREATE OR REPLACE VIEW public.v_platform_billing_leftovers
60
+ WITH (security_invoker = true) AS
61
+ SELECT c.relname::text AS table_name,
62
+ (SELECT n_live_tup FROM pg_stat_user_tables s WHERE s.relid = c.oid) AS approx_rows
63
+ FROM pg_class c
64
+ JOIN pg_namespace n ON n.oid = c.relnamespace
65
+ WHERE n.nspname = 'public'
66
+ AND c.relkind = 'r'
67
+ AND c.relname IN ('invoices', 'payment_events', 'subscriptions');
68
+
69
+ COMMENT ON VIEW public.v_platform_billing_leftovers IS
70
+ 'Fayz Cloud-era platform billing tables still present in this pool (166, #183). Empty is the expected reading — 166 drops them when they hold nothing. A row here means that cluster still records what the TENANT pays Fayz, which belongs on the platform side.';
71
+
72
+ GRANT SELECT ON public.v_platform_billing_leftovers TO service_role;
@@ -0,0 +1,112 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 167_a_view_gets_back_what_its_table_grants.sql — undoing the half of 165 that
3
+ -- was too blunt.
4
+ --
5
+ -- ── What 165 got right, and what it broke ──────────────────────────────────
6
+ -- 165 closed a real hole: `ALTER DEFAULT PRIVILEGES` gives every view postgres
7
+ -- creates in `public` full write access for **anon**, and 85 of 94 views on the
8
+ -- ecommerce pool carried it. Revoking anon was correct and stays.
9
+ --
10
+ -- Revoking `authenticated` in the same sweep was not. A signed-in user's writes
11
+ -- are governed by **RLS**, which is the actual control — and several of these
12
+ -- views are not read models at all but **compat aliases** created by 141's
13
+ -- rename mechanism, whose entire purpose is that the old name keeps working
14
+ -- exactly as it did. A read-only alias does not make a rename unsynchronised;
15
+ -- it moves the breakage from "the table is gone" to "permission denied for
16
+ -- view", which is worse because it looks like a permissions bug.
17
+ --
18
+ -- Measured after 165, on the ecommerce pool: **94 views** lost INSERT for
19
+ -- `authenticated`. Most are read models nobody writes. These are not:
20
+ --
21
+ -- shipping_zones · shipping_settings the merchant's delivery screen —
22
+ -- `packages/shop/src/data/tables.ts`
23
+ -- still writes through the old name
24
+ -- plg_connections · plg_connection_secrets · plg_connector_schedules
25
+ -- connector setup
26
+ -- plg_sync_runs · plg_sync_schedule connector sync
27
+ -- plg_event_log the domain-event log's own alias
28
+ -- v_documents attachment uploads
29
+ --
30
+ -- ── The rule this file states ──────────────────────────────────────────────
31
+ -- A view gets back exactly what its underlying table grants — no more, no less.
32
+ -- That is 141's alias rule made mechanical instead of hand-written, and it is
33
+ -- self-correcting: a table that is tightened later tightens its alias on the
34
+ -- next run, and a view over a table nobody may write stays unwritable.
35
+ --
36
+ -- `anon` is excluded unconditionally. It is the role the hole was about, and no
37
+ -- alias needs it: an anonymous writer reaches the base table through its own
38
+ -- policy or not at all.
39
+ -- ---------------------------------------------------------------------------
40
+
41
+ DO $$
42
+ DECLARE
43
+ v_view record;
44
+ v_priv text;
45
+ v_fixed integer := 0;
46
+ BEGIN
47
+ FOR v_view IN
48
+ -- Only views over exactly ONE base table can be written through at all
49
+ -- (Postgres refuses the rest), so those are the only ones worth granting.
50
+ SELECT u.view_name, min(u.table_name) AS base_table
51
+ FROM information_schema.view_table_usage u
52
+ WHERE u.view_schema = 'public' AND u.table_schema = 'public'
53
+ GROUP BY u.view_name
54
+ HAVING count(DISTINCT u.table_name) = 1
55
+ LOOP
56
+ IF to_regclass('public.' || quote_ident(v_view.view_name)) IS NULL
57
+ OR to_regclass('public.' || quote_ident(v_view.base_table)) IS NULL THEN
58
+ CONTINUE;
59
+ END IF;
60
+
61
+ FOREACH v_priv IN ARRAY ARRAY['INSERT', 'UPDATE', 'DELETE']
62
+ LOOP
63
+ -- Mirror, never widen: the table decides.
64
+ IF has_table_privilege('authenticated', format('public.%I', v_view.base_table), v_priv)
65
+ AND NOT has_table_privilege('authenticated', format('public.%I', v_view.view_name), v_priv) THEN
66
+ EXECUTE format('GRANT %s ON public.%I TO authenticated', v_priv, v_view.view_name);
67
+ v_fixed := v_fixed + 1;
68
+ END IF;
69
+ END LOOP;
70
+ END LOOP;
71
+
72
+ RAISE NOTICE '167: restored % view privilege(s) to authenticated, mirroring the base table', v_fixed;
73
+ END $$;
74
+
75
+ -- anon stays out, whatever the table says. This is the half of 165 that was
76
+ -- right, restated so a re-run of this file cannot undo it.
77
+ DO $$
78
+ DECLARE
79
+ v_view record;
80
+ BEGIN
81
+ FOR v_view IN
82
+ SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
83
+ WHERE n.nspname = 'public' AND c.relkind IN ('v', 'm')
84
+ AND (has_table_privilege('anon', c.oid, 'INSERT')
85
+ OR has_table_privilege('anon', c.oid, 'UPDATE')
86
+ OR has_table_privilege('anon', c.oid, 'DELETE'))
87
+ LOOP
88
+ EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.%I FROM anon', v_view.relname);
89
+ END LOOP;
90
+ END $$;
91
+
92
+ -- ── the census, both directions ────────────────────────────────────────────
93
+ --
94
+ -- 165 gave us "which views can anon write". The other half was missing and is
95
+ -- what this file exists because of: which views an alias's users can NO LONGER
96
+ -- write, though the table beneath still allows it.
97
+
98
+ CREATE OR REPLACE VIEW public.v_views_missing_table_grants
99
+ WITH (security_invoker = true) AS
100
+ SELECT u.view_name::text,
101
+ min(u.table_name)::text AS base_table
102
+ FROM information_schema.view_table_usage u
103
+ WHERE u.view_schema = 'public' AND u.table_schema = 'public'
104
+ GROUP BY u.view_name
105
+ HAVING count(DISTINCT u.table_name) = 1
106
+ AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
107
+ AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', u.view_name), 'INSERT'));
108
+
109
+ COMMENT ON VIEW public.v_views_missing_table_grants IS
110
+ 'Views a signed-in user can no longer write although the table beneath still allows it (167). Zero is the expected reading — a compat alias that lost its grants reads as a permissions bug on a screen, which is the most expensive kind of breakage to diagnose.';
111
+
112
+ GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
@@ -0,0 +1,33 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 168_the_grant_census_stops_crying_wolf.sql — 167's census counted a view over
3
+ -- a view.
4
+ --
5
+ -- `information_schema.view_table_usage` reports every relation a view reads,
6
+ -- and a VIEW is one of them. So `plg_crm_rep_leads`, which reads `v_leads`,
7
+ -- looked like an alias missing its table's grants — when neither is writable
8
+ -- and neither ever will be.
9
+ --
10
+ -- A census that reads 1 forever is a census people learn to ignore, which is
11
+ -- worse than not having one. It now counts only views over an actual TABLE.
12
+ -- ---------------------------------------------------------------------------
13
+
14
+ CREATE OR REPLACE VIEW public.v_views_missing_table_grants
15
+ WITH (security_invoker = true) AS
16
+ SELECT u.view_name::text,
17
+ min(u.table_name)::text AS base_table
18
+ FROM information_schema.view_table_usage u
19
+ JOIN pg_class c ON c.relname = u.table_name
20
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = u.table_schema
21
+ WHERE u.view_schema = 'public' AND u.table_schema = 'public'
22
+ -- A base that is itself a view cannot be written through either, so a
23
+ -- missing grant there is not a regression, it is the shape of the thing.
24
+ AND c.relkind = 'r'
25
+ GROUP BY u.view_name
26
+ HAVING count(DISTINCT u.table_name) = 1
27
+ AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
28
+ AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', u.view_name), 'INSERT'));
29
+
30
+ COMMENT ON VIEW public.v_views_missing_table_grants IS
31
+ 'Views a signed-in user can no longer write although the TABLE beneath still allows it (167, narrowed by 168). Zero is the expected reading — a compat alias that lost its grants reads as a permissions bug on a screen, which is the most expensive kind of breakage to diagnose.';
32
+
33
+ GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
@@ -0,0 +1,45 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 169_the_census_asks_postgres_instead_of_guessing.sql — third and last attempt
3
+ -- at the same census, this time asking the only source that knows.
4
+ --
5
+ -- The question is "which compat alias lost the write access its table has".
6
+ -- 167 approximated it as "a view reading exactly one relation", which counted
7
+ -- a view over a view. 168 narrowed to a view over a TABLE, which then counted
8
+ -- seven aggregate read models — `v_financial_ledger`, `v_invoice_balances`,
9
+ -- `plg_courses_rep_sales` and friends. Each reads one table and none is
10
+ -- writable, because they aggregate.
11
+ --
12
+ -- Both attempts were guessing at a property Postgres already computes:
13
+ -- `information_schema.views.is_insertable_into` is YES only for a view you can
14
+ -- actually INSERT through. A view that aggregates, joins, DISTINCTs or windows
15
+ -- is NO, and no grant changes that.
16
+ --
17
+ -- The lesson is worth more than the fix. Two rounds of a census reading
18
+ -- non-zero for a reason nobody needed to act on is exactly how a check gets
19
+ -- ignored — and a check people ignore is worse than no check, because it
20
+ -- occupies the space where a real one would go.
21
+ -- ---------------------------------------------------------------------------
22
+
23
+ CREATE OR REPLACE VIEW public.v_views_missing_table_grants
24
+ WITH (security_invoker = true) AS
25
+ SELECT v.table_name::text AS view_name,
26
+ min(u.table_name)::text AS base_table
27
+ FROM information_schema.views v
28
+ JOIN information_schema.view_table_usage u
29
+ ON u.view_schema = v.table_schema AND u.view_name = v.table_name
30
+ JOIN pg_class c ON c.relname = u.table_name
31
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = u.table_schema
32
+ WHERE v.table_schema = 'public'
33
+ AND u.table_schema = 'public'
34
+ -- The whole predicate, from the planner rather than from a heuristic.
35
+ AND v.is_insertable_into = 'YES'
36
+ AND c.relkind = 'r'
37
+ GROUP BY v.table_name
38
+ HAVING count(DISTINCT u.table_name) = 1
39
+ AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
40
+ AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', v.table_name), 'INSERT'));
41
+
42
+ COMMENT ON VIEW public.v_views_missing_table_grants IS
43
+ 'Views a signed-in user can no longer write although the table beneath allows it AND Postgres considers the view insertable (167→169). Zero is the expected reading. An alias that lost its grants reads as a permissions bug on a screen — the most expensive kind to diagnose, and the reason 165 had to be narrowed.';
44
+
45
+ GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.11.0",
6
+ "version": "0.13.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,
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "files": [
21
21
  "dist",
22
- "migrations"
22
+ "migrations",
23
+ "canonical-schema.json"
23
24
  ],
24
25
  "dependencies": {
25
26
  "drizzle-orm": "^0.36.4"
@@ -38,12 +39,12 @@
38
39
  ],
39
40
  "peerDependencies": {},
40
41
  "scripts": {
41
- "build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
42
+ "build": "tsup && tsc -b --force",
42
43
  "dev": "tsup --watch",
43
- "typecheck": "tsc --noEmit",
44
+ "typecheck": "tsc -b",
44
45
  "test": "bash test/run-migrations.sh",
45
46
  "test:migrations": "bash test/run-migrations.sh",
46
47
  "test:negative-control": "bash test/negative-control.sh",
47
- "clean": "rm -rf dist"
48
+ "clean": "rm -rf dist .tsbuildinfo"
48
49
  }
49
50
  }