@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,75 @@
1
+ -- ============================================================================
2
+ -- 143_a_professional_has_a_rate.sql — a commission rate belongs to the hat, not
3
+ -- to a table that no longer exists.
4
+ --
5
+ -- plugin-agenda's financial bridge computes a professional's commission like
6
+ -- this:
7
+ --
8
+ -- .from('v_staff').select('commission_rate').eq('id', order.assignee_id)
9
+ --
10
+ -- and its own comment says "per-professional rate lives on staff_members,
11
+ -- surfaced via v_staff". Neither half is true. `v_staff` is not declared by any
12
+ -- file in this chain — it survives on two pools as a leftover and is simply
13
+ -- absent on the other two — and where it DOES survive, its definition has no
14
+ -- `commission_rate` column. Nor does `staff_members`. Nor does anything else in
15
+ -- any of the four pools.
16
+ --
17
+ -- So the feature has been dead across the whole fleet, and quietly: PostgREST
18
+ -- answers with an error, the bridge reads `staff?.commission_rate` off a null,
19
+ -- `Number(undefined) || 0` gives 0, the amount is 0, and it returns null as if
20
+ -- the professional simply had no rate configured. Nobody sees a failure; the
21
+ -- commissions just never accrue. Found by asking each pool whether the objects
22
+ -- the code calls are actually there (scripts/check-pool-objects.mjs) — the
23
+ -- check that exists because reading the repo cannot answer that question.
24
+ --
25
+ -- WHERE IT GOES. `person_roles` is where a Person's professional hat lives, and
26
+ -- a commission rate is an attribute OF THAT HAT, not of the human: the same
27
+ -- person can be a professional in one unit at one rate and an employee in
28
+ -- another. A row per (person, role, unit) is already exactly that shape.
29
+ --
30
+ -- A PROMOTED COLUMN, NOT metadata. The same reasoning ADR 0009 used for
31
+ -- service_kind: money logic reasons about this value, so it gets a type, a
32
+ -- CHECK and a name — not a JSONB key that any writer can misspell into silence.
33
+ --
34
+ -- NULL means "no rate configured", which is what every professional in every
35
+ -- pool has today. Behaviour is therefore unchanged until someone sets one; what
36
+ -- changes is that setting one now works.
37
+ -- ============================================================================
38
+
39
+ ALTER TABLE public.person_roles
40
+ ADD COLUMN IF NOT EXISTS commission_rate numeric(7,4);
41
+
42
+ -- A rate, not an amount: 0.15 is fifteen percent. Bounded because a commission
43
+ -- above the sale is not a configuration anyone means, and below zero is a
44
+ -- deduction wearing the wrong name.
45
+ ALTER TABLE public.person_roles DROP CONSTRAINT IF EXISTS person_roles_commission_rate_check;
46
+ ALTER TABLE public.person_roles
47
+ ADD CONSTRAINT person_roles_commission_rate_check
48
+ CHECK (commission_rate IS NULL OR (commission_rate >= 0 AND commission_rate <= 1));
49
+
50
+ COMMENT ON COLUMN public.person_roles.commission_rate IS
51
+ 'Commission this person earns wearing THIS role in THIS unit, as a rate (0.15 = 15%). NULL = none configured. Promoted column, not metadata (ADR 0009): the financial bridge computes money from it.';
52
+
53
+ -- The read the bridge makes, named once so it does not re-derive "who is the
54
+ -- professional on this order" in TypeScript. One row per person: a professional
55
+ -- hat in a specific unit wins over a tenant-wide one, because the more specific
56
+ -- assignment is the one someone actually decided.
57
+ CREATE OR REPLACE VIEW public.v_professional_rates
58
+ WITH (security_invoker = true) AS
59
+ SELECT DISTINCT ON (r.person_id)
60
+ r.person_id AS id,
61
+ r.tenant_id,
62
+ r.unit_id,
63
+ r.role,
64
+ p.name,
65
+ r.commission_rate
66
+ FROM public.person_roles r
67
+ JOIN public.people p ON p.id = r.person_id
68
+ WHERE r.role IN ('professional', 'employee')
69
+ AND (r.valid_to IS NULL OR r.valid_to > now())
70
+ ORDER BY r.person_id, (r.unit_id IS NULL), r.is_primary DESC, r.created_at;
71
+
72
+ GRANT SELECT ON public.v_professional_rates TO authenticated;
73
+
74
+ COMMENT ON VIEW public.v_professional_rates IS
75
+ 'A professional and the commission rate of the role they wear (143). Replaces the undeclared v_staff the agenda bridge used to ask.';
@@ -0,0 +1,57 @@
1
+ -- ============================================================================
2
+ -- 144_a_product_can_be_sold_in_sizes.sql — a linha do pedido aprende a variante.
3
+ --
4
+ -- O ARQUÉTIPO JÁ EXISTE, e isso é a descoberta que este arquivo registra.
5
+ -- `public.product_variants` foi criada pela 086 na Fase 0 — com sku, preço
6
+ -- absoluto ou delta, `attributes` jsonb, o scaffold de authz e a regra de
7
+ -- liberação por unidade. Está completa, está correta, e está VAZIA nas quatro
8
+ -- pools, enquanto a loja carrega 121 variantes numa tabela paralela sua.
9
+ --
10
+ -- Então este arquivo não cria nada de catálogo. Ele acrescenta as duas únicas
11
+ -- coisas que faltavam para o arquétipo ser usável por quem precisa dele:
12
+ --
13
+ -- §1 is_default — a variante que responde quando ninguém escolheu
14
+ -- §2 order_items.variant_id — a linha do pedido dizendo qual foi vendida
15
+ --
16
+ -- A SEGUNDA É O MOTIVO DE TUDO. Sem ela, uma FK de public.order_items para
17
+ -- public.plg_shop_product_variants faria a BASE depender de um plugin
18
+ -- instalável, atravessando o prefixo que o ADR 0015 define como fronteira. Na
19
+ -- direção plugin → base isso é permitido e comum; nesta, não — um cluster sem a
20
+ -- loja instalada ficaria com uma coluna apontando para o nada.
21
+ --
22
+ -- E é o que destrava o estoque: enquanto o saldo não puder ser contado por
23
+ -- variante, duas camisetas de tamanhos diferentes são o mesmo saldo, e a loja
24
+ -- continua precisando do livro dela.
25
+ --
26
+ -- O backfill das 121 linhas NÃO está aqui: a spine roda antes dos plugins e
27
+ -- plg_shop_product_variants ainda não existe neste ponto da cadeia. Ele vive em
28
+ -- packages/shop/migrations/0075, que é o lado que pode conhecer os dois.
29
+ -- ============================================================================
30
+
31
+ -- §1 ── a variante padrão ────────────────────────────────────────────────────
32
+ -- Produto é variante com uma só, e é esta coluna que faz a frase valer no dado
33
+ -- em vez de só no discurso: um produto sem variação real ainda tem uma linha em
34
+ -- product_variants, marcada aqui, e todo leitor que pergunta "qual variante?"
35
+ -- recebe resposta sem precisar saber se aquele produto varia.
36
+ ALTER TABLE public.product_variants
37
+ ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
38
+
39
+ COMMENT ON COLUMN public.product_variants.is_default IS
40
+ 'A variante que responde quando ninguém escolheu (144). Um produto sem variação real tem uma linha, e é esta.';
41
+
42
+ -- Uma padrão por produto. Parcial, porque a regra é sobre as que SÃO padrão.
43
+ CREATE UNIQUE INDEX IF NOT EXISTS product_variants_one_default_uidx
44
+ ON public.product_variants (tenant_id, product_id) WHERE is_default;
45
+
46
+ -- §2 ── a linha do pedido aprende qual variante foi vendida ─────────────────
47
+ -- Nullable, e NULL é o significado que toda linha de hoje já tem: o produto
48
+ -- inteiro. Nenhum backfill é obrigatório — as 1.422 linhas do ecommerce seguem
49
+ -- válidas sem tocar em nenhuma, e um salão nunca preenche a coluna.
50
+ ALTER TABLE public.order_items
51
+ ADD COLUMN IF NOT EXISTS variant_id uuid REFERENCES public.product_variants(id) ON DELETE SET NULL;
52
+
53
+ COMMENT ON COLUMN public.order_items.variant_id IS
54
+ 'Qual variação foi vendida (144). NULL = o produto inteiro, que é o caso de todo negócio que não vende por tamanho.';
55
+
56
+ CREATE INDEX IF NOT EXISTS order_items_variant_idx
57
+ ON public.order_items (variant_id) WHERE variant_id IS NOT NULL;
@@ -0,0 +1,123 @@
1
+ -- ============================================================================
2
+ -- 145_the_app_says_which_tenant_it_is_in.sql — o arquétipo volta a ser legível
3
+ -- por quem tem mais de uma conta.
4
+ --
5
+ -- O DEFEITO, encontrado em produção. O tenant Artorius Steakhouse parou de
6
+ -- listar pedidos e clientes assim que uma tela passou a ler o arquétipo. A
7
+ -- causa não era a tela: é que as duas camadas resolvem tenant de jeitos
8
+ -- diferentes.
9
+ --
10
+ -- plg_shop_customers tenant_id IN (SELECT user_tenant_ids())
11
+ -- people tenant_id = app.current_tenant_id() AND …
12
+ --
13
+ -- A do plugin é por FILIAÇÃO: qualquer membro logado passa, e a consulta se
14
+ -- estreita sozinha com o `.eq('tenant_id', …)` que o app já manda. A do
15
+ -- arquétipo é por CONTEXTO, e `app.current_tenant_id()` só sabe responder de
16
+ -- duas formas: a claim `tenant_id` do JWT, ou — se não houver claim — o único
17
+ -- tenant do usuário. Se ele tem dois, ela devolve NULL, e NULL nega tudo.
18
+ --
19
+ -- NENHUM APP DA FROTA EMITE ESSA CLAIM. O token é um token comum do Supabase.
20
+ -- Então o arquétipo inteiro só era legível para quem pertence a exatamente uma
21
+ -- conta — e isso funcionou por acidente: no salão, 11 de 12 usuários têm uma
22
+ -- conta só. No ecommerce, três donos têm quatro e cinco, e para esses três
23
+ -- `public.people`, `public.orders` e `public.products` sempre responderam vazio.
24
+ -- O defeito é anterior a qualquer tela nova; ela só o tornou visível.
25
+ --
26
+ -- A SAÍDA JÁ EXISTE NESTE REPOSITÓRIO. `x-fayz-unit` viaja em toda requisição
27
+ -- desde 039 e `public.requested_unit()` a lê, sob uma regra que o cliente
28
+ -- enuncia melhor do que eu conseguiria:
29
+ --
30
+ -- "a header cannot be trusted to narrow, and it must never be able to
31
+ -- widen: the policies resolve unit access through user_unit_ids(), never
32
+ -- through what was sent here"
33
+ --
34
+ -- `x-fayz-tenant` entra pela mesma porta e sob a mesma regra. O cabeçalho não
35
+ -- concede nada: ele ESCOLHE entre as contas que o usuário já tem, e a escolha é
36
+ -- validada contra app.memberships exatamente como a claim do JWT já era. Um
37
+ -- cabeçalho forjado só alcança o que `user_tenant_ids()` — a política que as
38
+ -- tabelas de plugin usam hoje — já alcançava. Ele estreita; nunca alarga.
39
+ --
40
+ -- A ORDEM IMPORTA e é a de confiança: claim do JWT primeiro (é assinada), depois
41
+ -- o cabeçalho, depois o recuo de conta única. Quem já emite a claim não muda de
42
+ -- comportamento.
43
+ -- ============================================================================
44
+
45
+ CREATE OR REPLACE FUNCTION app.requested_tenant()
46
+ RETURNS uuid
47
+ LANGUAGE plpgsql STABLE
48
+ SET search_path = ''
49
+ AS $$
50
+ DECLARE v text;
51
+ BEGIN
52
+ v := nullif(current_setting('request.headers', true), '')::json ->> 'x-fayz-tenant';
53
+ RETURN nullif(v, '')::uuid;
54
+ -- Engole a falha de parse pela mesma razão que requested_unit(): uma função
55
+ -- vizinha da RLS não é lugar de levantar exceção.
56
+ EXCEPTION WHEN OTHERS THEN
57
+ RETURN NULL;
58
+ END $$;
59
+
60
+ REVOKE ALL ON FUNCTION app.requested_tenant() FROM public;
61
+ GRANT EXECUTE ON FUNCTION app.requested_tenant() TO authenticated, service_role;
62
+
63
+ CREATE OR REPLACE FUNCTION app.current_tenant_id()
64
+ RETURNS uuid
65
+ LANGUAGE plpgsql STABLE SECURITY DEFINER
66
+ SET search_path = ''
67
+ AS $$
68
+ DECLARE
69
+ v_uid uuid := auth.uid();
70
+ v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
71
+ v_raw text;
72
+ v_tenant uuid;
73
+ v_n integer;
74
+ BEGIN
75
+ -- ① a claim assinada, se existir. Inalterado desde 051.
76
+ IF jsonb_exists(v_jwt, 'tenant_id') THEN
77
+ v_raw := v_jwt ->> 'tenant_id';
78
+ -- Presença expressa intenção de estreitar; valor ilegível é nenhum tenant.
79
+ IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
80
+ RETURN NULL;
81
+ END IF;
82
+ v_tenant := v_raw::uuid;
83
+ IF v_uid IS NULL THEN
84
+ RETURN v_tenant;
85
+ END IF;
86
+ IF EXISTS (SELECT 1 FROM app.memberships m
87
+ WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
88
+ RETURN v_tenant;
89
+ END IF;
90
+ RETURN NULL;
91
+ END IF;
92
+
93
+ IF v_uid IS NULL THEN
94
+ RETURN NULL;
95
+ END IF;
96
+
97
+ -- ② o cabeçalho da requisição. A MESMA validação de filiação de ①: ele
98
+ -- escolhe entre as contas do usuário, não concede nenhuma. Sem sessão não há
99
+ -- o que validar, então este braço exige v_uid — que já está garantido acima.
100
+ v_tenant := app.requested_tenant();
101
+ IF v_tenant IS NOT NULL THEN
102
+ IF EXISTS (SELECT 1 FROM app.memberships m
103
+ WHERE m.tenant_id = v_tenant AND m.user_id = v_uid AND m.active) THEN
104
+ RETURN v_tenant;
105
+ END IF;
106
+ -- Cabeçalho apontando para conta que não é sua: nega, não recua. Recuar
107
+ -- para "sua única conta" transformaria um pedido explícito e errado numa
108
+ -- resposta silenciosa sobre outra conta.
109
+ RETURN NULL;
110
+ END IF;
111
+
112
+ -- ③ o recuo: conta única. Quem tem duas e não disse qual continua sem tenant,
113
+ -- que é a resposta correta — só que agora ela é evitável.
114
+ SELECT count(*), (array_agg(m.tenant_id))[1] INTO v_n, v_tenant
115
+ FROM app.memberships m WHERE m.user_id = v_uid AND m.active;
116
+ IF v_n = 1 THEN
117
+ RETURN v_tenant;
118
+ END IF;
119
+ RETURN NULL;
120
+ END $$;
121
+
122
+ COMMENT ON FUNCTION app.current_tenant_id() IS
123
+ 'Em qual conta esta requisição está agindo: claim assinada do JWT, senão o cabeçalho x-fayz-tenant, senão a única conta do usuário (145). Os três caminhos validam filiação — nenhum concede acesso, todos escolhem entre o que já é do usuário.';
@@ -0,0 +1,67 @@
1
+ -- ============================================================================
2
+ -- 146_products_stock_has_one_writer.sql — quem manda no estoque do produto.
3
+ --
4
+ -- O último item da Fase 0 com forma de loja. A #143 registrou assim:
5
+ -- "products.stock has two independent writers (inventory positions vs shop
6
+ -- inventory_count) with no arbitration". Dois gatilhos escrevem a mesma coluna:
7
+ --
8
+ -- plugin-inventory 014 UPDATE products SET stock = Σ posições
9
+ -- shop 0009 UPDATE products SET stock = NEW.inventory_count
10
+ --
11
+ -- Sem árbitro, quem dispara por último ganha. E não é hipótese: medido em
12
+ -- 25/08, restaurant tem 20 unidades de diferença entre os dois números e salon
13
+ -- tem 29. No ecommerce, products.stock vale 1.200.449 (o número da loja) contra
14
+ -- 12 em posições — se o espelho do inventory disparasse para aqueles produtos,
15
+ -- o estoque da loja colapsaria de um milhão para uma dúzia.
16
+ --
17
+ -- POR QUE UM REGISTRO E NÃO UMA REGRA INFERIDA. A tentação é decidir na hora:
18
+ -- "o inventory ganha se tiver movimento". Só o Artorius Steakhouse tem os dois
19
+ -- livros — 2 movimentos do inventory contra 24 da loja — e essa regra o
20
+ -- entregaria ao inventory, colapsando exatamente o estoque que o lojista vê.
21
+ -- Uma coluna que muda de dono conforme o dado do dia não tem dono.
22
+ --
23
+ -- O PADRÃO É O CONTRATO DECLARADO. O 012 do inventory já diz que products.stock
24
+ -- "become one-way MIRRORS maintained by 007", então a ausência de registro
25
+ -- significa inventory. A loja registra a EXCEÇÃO onde ela é, de fato, quem
26
+ -- mantém o número — e registra por linha, não por DDL, que é a mesma forma que
27
+ -- 080 usou para as espécies de venda.
28
+ --
29
+ -- Este arquivo não conhece nenhum plugin: declara a mesa e a pergunta. Quem
30
+ -- responde "sou eu" é cada plugin, no arquivo dele.
31
+ -- ============================================================================
32
+
33
+ CREATE TABLE IF NOT EXISTS app.product_stock_source (
34
+ tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
35
+ source text NOT NULL CHECK (source IN ('inventory', 'shop')),
36
+ note text,
37
+ created_at timestamptz NOT NULL DEFAULT now(),
38
+ updated_at timestamptz NOT NULL DEFAULT now()
39
+ );
40
+
41
+ COMMENT ON TABLE app.product_stock_source IS
42
+ 'Quem mantém public.products.stock nesta conta (146). Linha ausente = inventory, que é o contrato declarado pelo 012. Um registro e não uma inferência: uma coluna que muda de dono conforme o dado do dia não tem dono.';
43
+
44
+ ALTER TABLE app.product_stock_source ENABLE ROW LEVEL SECURITY;
45
+ DROP POLICY IF EXISTS product_stock_source_read ON app.product_stock_source;
46
+ CREATE POLICY product_stock_source_read ON app.product_stock_source
47
+ FOR SELECT TO authenticated USING (true);
48
+ GRANT SELECT ON app.product_stock_source TO authenticated;
49
+
50
+ CREATE OR REPLACE FUNCTION app.product_stock_owner(p_tenant uuid)
51
+ RETURNS text
52
+ LANGUAGE sql STABLE SECURITY DEFINER
53
+ SET search_path = ''
54
+ AS $$
55
+ SELECT coalesce(
56
+ (SELECT s.source FROM app.product_stock_source s WHERE s.tenant_id = p_tenant),
57
+ 'inventory')
58
+ $$;
59
+
60
+ COMMENT ON FUNCTION app.product_stock_owner(uuid) IS
61
+ 'Qual livro mantém products.stock nesta conta: inventory (padrão, contrato do 012) ou shop. Todo escritor daquela coluna pergunta antes de escrever — é o que a #143 chamava de arbitragem ausente.';
62
+
63
+ REVOKE ALL ON FUNCTION app.product_stock_owner(uuid) FROM public;
64
+ GRANT EXECUTE ON FUNCTION app.product_stock_owner(uuid) TO authenticated, service_role;
65
+
66
+ COMMENT ON COLUMN public.products.stock IS
67
+ 'deprecated como fonte: o saldo real vive no livro de estoque. Mantido como espelho de UM escritor só, escolhido por app.product_stock_owner(tenant) (146). Nunca escrever sem perguntar — dois escritores nesta coluna foi a #143.';