ekwo-os 0.4.1
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.
- package/README.md +873 -0
- package/dist/args.d.ts +57 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +134 -0
- package/dist/args.js.map +1 -0
- package/dist/assets/expected-objects.json +9432 -0
- package/dist/assets/migrations/20260911120000_core_companies.sql +260 -0
- package/dist/assets/migrations/20260911120100_accounts_journals.sql +211 -0
- package/dist/assets/migrations/20260911120200_contacts.sql +90 -0
- package/dist/assets/migrations/20260911120300_taxes.sql +146 -0
- package/dist/assets/migrations/20260911120400_entries.sql +338 -0
- package/dist/assets/migrations/20260911120500_documents.sql +279 -0
- package/dist/assets/migrations/20260911120600_payments_reconciliation.sql +251 -0
- package/dist/assets/migrations/20260911120700_bank.sql +178 -0
- package/dist/assets/migrations/20260911120800_analytics_attachments.sql +128 -0
- package/dist/assets/migrations/20260911120900_post_document.sql +279 -0
- package/dist/assets/migrations/20260911121000_reporting.sql +235 -0
- package/dist/assets/migrations/20260911121100_country_templates.sql +226 -0
- package/dist/assets/migrations/20260911121200_fec.sql +71 -0
- package/dist/assets/migrations/20260911130000_instance.sql +77 -0
- package/dist/assets/migrations/20260911130100_instance_members.sql +224 -0
- package/dist/assets/migrations/20260911140000_instance_admins.sql +112 -0
- package/dist/assets/migrations/20260911160000_tax_posting_templates_unique.sql +17 -0
- package/dist/assets/migrations/20260911170000_document_amount_paid.sql +82 -0
- package/dist/assets/migrations/20260911173000_post_payment.sql +122 -0
- package/dist/assets/migrations/20260911173100_sequence_counters_under_rls.sql +84 -0
- package/dist/assets/migrations/20260911183000_country_journal_defaults.sql +143 -0
- package/dist/assets/migrations/20260911193853_line_account_defaults.sql +274 -0
- package/dist/assets/migrations/20260911195054_products.sql +221 -0
- package/dist/assets/migrations/20260911210131_anon_surface.sql +85 -0
- package/dist/assets/migrations/20260912074712_country_packs.sql +318 -0
- package/dist/assets/migrations/20260912080311_report_code_and_region.sql +198 -0
- package/dist/assets/migrations/20260912081014_pack_certification_maintained.sql +31 -0
- package/dist/assets/migrations/20260912081015_pack_certification_backfill.sql +16 -0
- package/dist/assets/migrations/20260912090407_tax_report_boxes.sql +329 -0
- package/dist/assets/migrations/20260912091917_tax_on_base_value.sql +25 -0
- package/dist/assets/migrations/20260912091918_tax_engine_columns.sql +776 -0
- package/dist/assets/migrations/20260912094412_opening_and_closing.sql +759 -0
- package/dist/assets/migrations/20260912095825_charts_of_accounts.sql +326 -0
- package/dist/assets/migrations/20260912100412_financial_statements.sql +632 -0
- package/dist/assets/migrations/20260912104719_one_formula_evaluator.sql +209 -0
- package/dist/assets/migrations/20260912105720_entry_kind_appropriation.sql +26 -0
- package/dist/assets/migrations/20260912105721_appropriation_entry_kind.sql +444 -0
- package/dist/assets/migrations/20260912111751_document_rules.sql +274 -0
- package/dist/assets/migrations/20260912112132_cash_basis_vat_and_fx.sql +1189 -0
- package/dist/assets/migrations/20260913074512_modules.sql +493 -0
- package/dist/assets/migrations/20260913075903_asset_disposal_roles.sql +47 -0
- package/dist/assets/migrations/20260913083216_capabilities.sql +637 -0
- package/dist/assets/migrations/20260913083901_company_invitations.sql +262 -0
- package/dist/assets/migrations/20260913084402_user_preferences.sql +376 -0
- package/dist/assets/migrations/20260913084847_company_profile.sql +261 -0
- package/dist/assets/migrations/20260913085436_numbering_reads_the_pack.sql +235 -0
- package/dist/assets/migrations/20260913085932_api_keys.sql +303 -0
- package/dist/assets/migrations/20260913090216_fiscal_year_bounds.sql +159 -0
- package/dist/assets/migrations/20260913092527_entries_import.sql +219 -0
- package/dist/assets/migrations/20260913101536_null_safe_guards.sql +188 -0
- package/dist/assets/migrations/20260913102115_installer_is_named.sql +462 -0
- package/dist/assets/migrations/20260913102758_no_currency_default.sql +138 -0
- package/dist/assets/migrations/20260913103355_cash_basis_needs_a_box.sql +453 -0
- package/dist/assets/migrations/20260913104014_aged_balance_named_group.sql +79 -0
- package/dist/assets/migrations/20260913104232_foreign_key_indexes.sql +144 -0
- package/dist/assets/migrations/20260913105120_declared_no_reader.sql +41 -0
- package/dist/assets/migrations/20260913111407_pack_languages.sql +223 -0
- package/dist/assets/migrations/20260913112233_schema_comments_for_a_reader.sql +27 -0
- package/dist/assets/migrations/20260913114535_fec_opening_balances.sql +307 -0
- package/dist/assets/migrations/20260914103412_audit_log.sql +509 -0
- package/dist/assets/migrations/20260914111907_pack_upgrade.sql +428 -0
- package/dist/assets/migrations/20260914120500_rounding_reads_the_currency.sql +206 -0
- package/dist/assets/migrations/20260914121200_amounts_round_at_the_currency.sql +2403 -0
- package/dist/assets/migrations/20260914134325_schema_version_0_2_0.sql +36 -0
- package/dist/assets/migrations/20260914143915_accounts_in_use.sql +428 -0
- package/dist/assets/migrations/20260914144731_account_code_frozen.sql +99 -0
- package/dist/assets/migrations/20260914151207_schema_grants_its_own_rights.sql +284 -0
- package/dist/assets/migrations/20260914152840_pack_upgrade_records_its_own_line.sql +237 -0
- package/dist/assets/migrations/20260914163943_declaration_periodicity.sql +349 -0
- package/dist/assets/migrations/20260915094000_foreign_services_received.sql +37 -0
- package/dist/assets/migrations/20260915094500_mentions_read_a_foreign_service.sql +72 -0
- package/dist/assets/migrations/20260915153000_document_shares.sql +568 -0
- package/dist/assets/migrations/20260915160000_ec_sales_list.sql +181 -0
- package/dist/assets/migrations/20260915161842_pack_sources.sql +56 -0
- package/dist/assets/migrations/20260915170500_schema_version_0_3_0.sql +45 -0
- package/dist/assets/migrations/20260915174500_a_vat_category_is_a_code.sql +177 -0
- package/dist/assets/migrations/20260915180000_document_rule_references.sql +85 -0
- package/dist/assets/migrations/20260915181000_territories.sql +342 -0
- package/dist/assets/migrations/20260915181500_ec_sales_list_reads_the_territories.sql +222 -0
- package/dist/assets/migrations/20260915182000_intracom_triangular.sql +53 -0
- package/dist/assets/migrations/20260915182500_a_triangular_supply_says_reverse_charge.sql +99 -0
- package/dist/assets/migrations/20260915191200_a_document_knows_its_language.sql +633 -0
- package/dist/assets/migrations/20260915195000_a_price_that_holds_its_tax.sql +649 -0
- package/dist/assets/migrations/20260915200000_a_posting_names_its_boxes.sql +1004 -0
- package/dist/assets/migrations/20260916094500_a_company_files_more_than_one_declaration.sql +795 -0
- package/dist/assets/migrations/20260916103000_a_ledger_line_names_its_posting.sql +902 -0
- package/dist/assets/migrations/20260916123000_a_vocabulary_for_a_tax_that_is_not_a_vat.sql +144 -0
- package/dist/assets/migrations/20260916124000_a_box_can_be_a_rate_of_a_box.sql +525 -0
- package/dist/assets/migrations/20260916125000_a_tax_follows_the_territory.sql +1318 -0
- package/dist/assets/migrations/20260916126000_a_tax_point_and_its_exception.sql +1211 -0
- package/dist/assets/migrations/20260917090000_a_counterparty_that_learns.sql +561 -0
- package/dist/assets/migrations/20260917120000_what_the_money_pays.sql +545 -0
- package/dist/assets/migrations/20260917150000_a_filing_is_a_row.sql +488 -0
- package/dist/assets/migrations/20260917170000_when_it_is_due.sql +194 -0
- package/dist/assets/migrations/20260917180000_a_box_is_a_number_and_a_kind.sql +213 -0
- package/dist/assets/migrations/20260917190000_what_a_declaration_owes.sql +486 -0
- package/dist/assets/migrations/20260917200000_what_moved_after_it_went.sql +227 -0
- package/dist/assets/migrations/20260918090000_a_form_names_its_file.sql +27 -0
- package/dist/assets/migrations/20260918110000_a_deposit_and_what_came_back.sql +286 -0
- package/dist/assets/migrations/20260918113741_a_fourth_preset_is_named.sql +9 -0
- package/dist/assets/migrations/20260918113807_a_client_reads_and_hands_over.sql +96 -0
- package/dist/assets/migrations/20260918114322_two_definer_functions_check_their_caller.sql +75 -0
- package/dist/assets/migrations/20260918130000_every_company_somebody_keeps.sql +177 -0
- package/dist/assets/migrations/20260918140000_a_session_nobody_prepared.sql +40 -0
- package/dist/assets/migrations/20260918141107_a_line_keeps_the_tax_it_was_posted_with.sql +256 -0
- package/dist/assets/migrations/20260918141342_a_company_has_an_electronic_address.sql +66 -0
- package/dist/assets/migrations/20260918141605_an_invoice_reads_whole_from_the_views.sql +313 -0
- package/dist/assets/migrations/20260918141627_a_policy_asks_once.sql +132 -0
- package/dist/assets/migrations/20260918143352_a_suggestion_reads_the_words_once.sql +204 -0
- package/dist/assets/migrations/20260918143417_a_reference_of_the_caller_and_a_rehearsal.sql +115 -0
- package/dist/assets/migrations/20260918150712_a_company_leaves_with_its_books.sql +1119 -0
- package/dist/assets/migrations/20260918150931_a_statement_is_imported_once.sql +654 -0
- package/dist/assets/migrations/20260918161204_a_posted_document_does_not_move.sql +437 -0
- package/dist/assets/migrations/20260918161538_a_posted_entry_does_not_move.sql +252 -0
- package/dist/assets/migrations/20260918171946_an_entry_is_posted_by_post_entry.sql +289 -0
- package/dist/assets/migrations/20260918174312_schema_version_0_4_0.sql +53 -0
- package/dist/assets/modules/assets/module.json +13 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913081447_assets.sql +1190 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104234_foreign_key_indexes.sql +47 -0
- package/dist/assets/modules/assets/supabase/migrations/20260913104500_assets_capabilities.sql +145 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914122100_assets_round_at_the_currency.sql +564 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914145012_assets_accounts_in_use.sql +37 -0
- package/dist/assets/modules/assets/supabase/migrations/20260914151530_assets_grants_its_own_rights.sql +63 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918113912_assets_client_reads.sql +20 -0
- package/dist/assets/modules/assets/supabase/migrations/20260918150801_assets_leave_with_the_company.sql +41 -0
- package/dist/assets/modules/budgets/module.json +12 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913083012_budgets.sql +210 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104233_foreign_key_indexes.sql +37 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260913104501_budgets_capabilities.sql +72 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914122400_budgets_round_at_the_currency.sql +68 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914145048_budgets_accounts_in_use.sql +30 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260914151812_budgets_grants_its_own_rights.sql +31 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918113954_budgets_client_reads.sql +20 -0
- package/dist/assets/modules/budgets/supabase/migrations/20260918150823_budgets_leave_with_the_company.sql +37 -0
- package/dist/assets/modules/schema/module.1.json +69 -0
- package/dist/assets/seed/00_currencies.sql +15 -0
- package/dist/assets/seed/00_territories.sql +280 -0
- package/dist/assets/seed/05_framework_generic.sql +110 -0
- package/dist/assets/seed/10_pack_be.sql +1284 -0
- package/dist/assets/seed/11_pack_fr.sql +1112 -0
- package/dist/assets/seed/12_pack_lu.sql +2484 -0
- package/dist/assets/seed/13_pack_ee.sql +733 -0
- package/dist/assets/seed/14_pack_gb.sql +796 -0
- package/dist/assets/seed/15_pack_us.sql +751 -0
- package/dist/assets/seed/90_demo_company.sql +368 -0
- package/dist/assets/seed/modules/assets/10_pack_be.sql +58 -0
- package/dist/assets/seed/modules/assets/11_pack_fr.sql +57 -0
- package/dist/assets/seed/modules/assets/14_pack_gb.sql +58 -0
- package/dist/assets/seed/modules/assets/15_pack_us.sql +60 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +134 -0
- package/dist/auth.js.map +1 -0
- package/dist/bin.d.ts +6 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/books.d.ts +60 -0
- package/dist/books.d.ts.map +1 -0
- package/dist/books.js +168 -0
- package/dist/books.js.map +1 -0
- package/dist/bootstrap.d.ts +236 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +459 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/bundle.d.ts +27 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +47 -0
- package/dist/bundle.js.map +1 -0
- package/dist/checklist.d.ts +35 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +64 -0
- package/dist/checklist.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +318 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/company.d.ts +59 -0
- package/dist/commands/company.d.ts.map +1 -0
- package/dist/commands/company.js +287 -0
- package/dist/commands/company.js.map +1 -0
- package/dist/commands/contact.d.ts +9 -0
- package/dist/commands/contact.d.ts.map +1 -0
- package/dist/commands/contact.js +82 -0
- package/dist/commands/contact.js.map +1 -0
- package/dist/commands/demo.d.ts +19 -0
- package/dist/commands/demo.d.ts.map +1 -0
- package/dist/commands/demo.js +72 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +68 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/document.d.ts +17 -0
- package/dist/commands/document.d.ts.map +1 -0
- package/dist/commands/document.js +217 -0
- package/dist/commands/document.js.map +1 -0
- package/dist/commands/init.d.ts +31 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +535 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/login.d.ts +24 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +128 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/migrate.d.ts +30 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +115 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/module.d.ts +32 -0
- package/dist/commands/module.d.ts.map +1 -0
- package/dist/commands/module.js +285 -0
- package/dist/commands/module.js.map +1 -0
- package/dist/commands/pack.d.ts +22 -0
- package/dist/commands/pack.d.ts.map +1 -0
- package/dist/commands/pack.js +481 -0
- package/dist/commands/pack.js.map +1 -0
- package/dist/commands/payment.d.ts +15 -0
- package/dist/commands/payment.d.ts.map +1 -0
- package/dist/commands/payment.js +97 -0
- package/dist/commands/payment.js.map +1 -0
- package/dist/commands/register.d.ts +20 -0
- package/dist/commands/register.d.ts.map +1 -0
- package/dist/commands/register.js +134 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +134 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/whoami.d.ts +21 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +133 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/company.d.ts +16 -0
- package/dist/company.d.ts.map +1 -0
- package/dist/company.js +26 -0
- package/dist/company.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +50 -0
- package/dist/config.js.map +1 -0
- package/dist/connection.d.ts +79 -0
- package/dist/connection.d.ts.map +1 -0
- package/dist/connection.js +116 -0
- package/dist/connection.js.map +1 -0
- package/dist/context.d.ts +47 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +93 -0
- package/dist/context.js.map +1 -0
- package/dist/doctor.d.ts +45 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +448 -0
- package/dist/doctor.js.map +1 -0
- package/dist/grants.d.ts +88 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +174 -0
- package/dist/grants.js.map +1 -0
- package/dist/identity.d.ts +54 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +132 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +155 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +303 -0
- package/dist/inventory.js.map +1 -0
- package/dist/migrations.d.ts +76 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +226 -0
- package/dist/migrations.js.map +1 -0
- package/dist/module/read.d.ts +91 -0
- package/dist/module/read.d.ts.map +1 -0
- package/dist/module/read.js +172 -0
- package/dist/module/read.js.map +1 -0
- package/dist/output.d.ts +110 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +173 -0
- package/dist/output.js.map +1 -0
- package/dist/pack/certification.d.ts +27 -0
- package/dist/pack/certification.d.ts.map +1 -0
- package/dist/pack/certification.js +38 -0
- package/dist/pack/certification.js.map +1 -0
- package/dist/pack/compile.d.ts +75 -0
- package/dist/pack/compile.d.ts.map +1 -0
- package/dist/pack/compile.js +801 -0
- package/dist/pack/compile.js.map +1 -0
- package/dist/pack/filing.d.ts +48 -0
- package/dist/pack/filing.d.ts.map +1 -0
- package/dist/pack/filing.js +56 -0
- package/dist/pack/filing.js.map +1 -0
- package/dist/pack/read.d.ts +766 -0
- package/dist/pack/read.d.ts.map +1 -0
- package/dist/pack/read.js +2336 -0
- package/dist/pack/read.js.map +1 -0
- package/dist/pack/schema.d.ts +26 -0
- package/dist/pack/schema.d.ts.map +1 -0
- package/dist/pack/schema.js +181 -0
- package/dist/pack/schema.js.map +1 -0
- package/dist/pack/territories.d.ts +98 -0
- package/dist/pack/territories.d.ts.map +1 -0
- package/dist/pack/territories.js +0 -0
- package/dist/pack/territories.js.map +1 -0
- package/dist/pack/upgrade.d.ts +86 -0
- package/dist/pack/upgrade.d.ts.map +1 -0
- package/dist/pack/upgrade.js +76 -0
- package/dist/pack/upgrade.js.map +1 -0
- package/dist/pack/vat-codes.d.ts +273 -0
- package/dist/pack/vat-codes.d.ts.map +1 -0
- package/dist/pack/vat-codes.js +513 -0
- package/dist/pack/vat-codes.js.map +1 -0
- package/dist/profiles.d.ts +67 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +187 -0
- package/dist/profiles.js.map +1 -0
- package/dist/prompt.d.ts +40 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +120 -0
- package/dist/prompt.js.map +1 -0
- package/dist/registry.d.ts +84 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +104 -0
- package/dist/registry.js.map +1 -0
- package/dist/rest.d.ts +81 -0
- package/dist/rest.d.ts.map +1 -0
- package/dist/rest.js +245 -0
- package/dist/rest.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +15 -0
- package/dist/schema.js.map +1 -0
- package/dist/seeds.d.ts +27 -0
- package/dist/seeds.d.ts.map +1 -0
- package/dist/seeds.js +47 -0
- package/dist/seeds.js.map +1 -0
- package/dist/session.d.ts +39 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +92 -0
- package/dist/session.js.map +1 -0
- package/dist/sql.d.ts +48 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +112 -0
- package/dist/sql.js.map +1 -0
- package/dist/status.d.ts +94 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +120 -0
- package/dist/status.js.map +1 -0
- package/dist/ui.d.ts +55 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +114 -0
- package/dist/ui.js.map +1 -0
- package/package.json +29 -0
- package/schema/output.1.json +613 -0
|
@@ -0,0 +1,1119 @@
|
|
|
1
|
+
-- Ekwo OS — a company leaves an installation with its books, and arrives in
|
|
2
|
+
-- another one alive.
|
|
3
|
+
--
|
|
4
|
+
-- A firm keeps the books of N companies in one installation. That is the
|
|
5
|
+
-- normal case and the schema was shaped for it. It is also a trap if nothing
|
|
6
|
+
-- else is said: `pg_dump` takes the whole installation or nothing, so the one
|
|
7
|
+
-- company whose owner changes accountant cannot be handed its ledger — only
|
|
8
|
+
-- the firm's goodwill can. "The client owns the books" was a sentence.
|
|
9
|
+
--
|
|
10
|
+
-- This migration makes it a function, in four parts.
|
|
11
|
+
--
|
|
12
|
+
-- **1. What belongs to a company is read from the catalogue, not from a
|
|
13
|
+
-- list.** A table is *of a company* when it carries `company_id`, or reaches
|
|
14
|
+
-- `companies` by a foreign key, directly or through another such table.
|
|
15
|
+
-- `company_scoped_tables()` works that out from `pg_constraint` and
|
|
16
|
+
-- `pg_attribute`. `company_archive_registry` then says, table by table, one of
|
|
17
|
+
-- two things: `exported`, or `excluded` with a sentence saying why. A table
|
|
18
|
+
-- that is of a company and says neither is *unclassified*, and an export
|
|
19
|
+
-- refuses to run while one exists — so the day somebody adds a table and
|
|
20
|
+
-- forgets this file, the archive does not quietly become incomplete. It stops,
|
|
21
|
+
-- by name. A test asks the same question of every checkout.
|
|
22
|
+
--
|
|
23
|
+
-- A module has its own tables and follows the same rule without the socle
|
|
24
|
+
-- knowing it: the convention is `<schema>.archive_tables()`, looked up the way
|
|
25
|
+
-- `disable_module()` looks up `<schema>.can_disable()`. A function in the
|
|
26
|
+
-- module's schema rather than rows in the socle's table, because a module
|
|
27
|
+
-- migration may be applied on a socle that predates this file, where the table
|
|
28
|
+
-- does not exist yet.
|
|
29
|
+
--
|
|
30
|
+
-- **2. Leaving needs a right of its own, `company.export`, and runs as the
|
|
31
|
+
-- person who holds it.** Every export function is `security invoker`: it reads
|
|
32
|
+
-- what row level security lets its caller read, and there is no exemption for
|
|
33
|
+
-- the installer or for `service_role` — whoever holds the database already has
|
|
34
|
+
-- `pg_dump`. What row level security would do on its own, though, is hand back
|
|
35
|
+
-- *fewer rows and say nothing*: a member whose `bank.read` was revoked would
|
|
36
|
+
-- leave with books that have no bank in them and a manifest that looks whole.
|
|
37
|
+
-- So each table is counted twice — as the caller, and by a definer function
|
|
38
|
+
-- that answers a number and nothing else — and a difference is
|
|
39
|
+
-- `export_incomplete`, by table.
|
|
40
|
+
--
|
|
41
|
+
-- The two readers are `stable`, so they read the snapshot of the statement
|
|
42
|
+
-- that calls them, and `export_company()` calls both in one statement: an
|
|
43
|
+
-- archive whose lines were read a moment after their entries is one that fails
|
|
44
|
+
-- on arrival, after the company has left. Read table by table, they need a
|
|
45
|
+
-- repeatable read transaction around them, which is what the CLI opens.
|
|
46
|
+
--
|
|
47
|
+
-- `owner` holds it, and so does `client`. The reasoning is in
|
|
48
|
+
-- `docs/decisions.md` under this date; the short form is that a right to leave
|
|
49
|
+
-- which only the firm can exercise is not a right of the client.
|
|
50
|
+
--
|
|
51
|
+
-- **3. The archive is rows, as JSON, table by table.** One object per row with
|
|
52
|
+
-- the column names the schema documents; decimals as strings, so that no
|
|
53
|
+
-- reader rounds an amount by parsing it; timestamps in UTC; rows in primary
|
|
54
|
+
-- key order. The checksum of a table is the sha256 of its rows written one per
|
|
55
|
+
-- line, which is also exactly the file the CLI writes — `shasum` checks an
|
|
56
|
+
-- archive without Ekwo. `docs/company-archive.md` is the format.
|
|
57
|
+
--
|
|
58
|
+
-- **4. Arriving is one call, by whoever may create a company here.**
|
|
59
|
+
-- `import_company()` does not replay the books through `post_entry()`: a
|
|
60
|
+
-- posted entry is immutable and numbered, and replaying it would draw new
|
|
61
|
+
-- numbers, stamp today's date on every act of the audit trail and ask the
|
|
62
|
+
-- period locks for permission to rebuild what they protect. It inserts the
|
|
63
|
+
-- rows, with the user triggers of the tables it fills switched off for the
|
|
64
|
+
-- length of its own transaction — which takes a lock that makes every other
|
|
65
|
+
-- writer of those tables wait, so nobody books without guards meanwhile —
|
|
66
|
+
-- foreign keys left on, and then checks what the triggers would have
|
|
67
|
+
-- guaranteed: every row belongs to the company that arrived, no reference
|
|
68
|
+
-- crosses into a company that was already here, every entry balances and
|
|
69
|
+
-- agrees with its lines, the matching agrees with the matched amounts, no
|
|
70
|
+
-- counter is behind a number or a letter already used, no two financial years
|
|
71
|
+
-- overlap. One failure and nothing of it stays.
|
|
72
|
+
--
|
|
73
|
+
-- It is `security definer`, because nobody holds a right on a company that
|
|
74
|
+
-- does not exist yet, and it checks its caller on its first line: the
|
|
75
|
+
-- installer, or an administrator of this installation — the two who may call
|
|
76
|
+
-- `create_company()` — written `is not true`, because `if not NULL` does not
|
|
77
|
+
-- raise: `is_installer()` answered NULL on a connection that never set
|
|
78
|
+
-- `ekwo.installing`, until `20260918140000`, and the first draft of this
|
|
79
|
+
-- function is how that was found. Identifiers are kept. A company that is already here is
|
|
80
|
+
-- refused, which is also the answer to running the same import twice.
|
|
81
|
+
--
|
|
82
|
+
-- What does not travel, and why, is in the registry rows below and in the
|
|
83
|
+
-- documentation: the people (`company_members`, `company_invitations`), the
|
|
84
|
+
-- secrets (`api_keys`, `document_shares`), a user's own preferences. The
|
|
85
|
+
-- identifiers of the people who acted stay on the rows as a trace — none of
|
|
86
|
+
-- those columns is a foreign key — and name nobody in the new installation.
|
|
87
|
+
-- The bytes of the attachments are not in the database and are not in the
|
|
88
|
+
-- archive: the manifest lists them, path, size and checksum.
|
|
89
|
+
|
|
90
|
+
-- ---------------------------------------------------------------------------
|
|
91
|
+
-- The right
|
|
92
|
+
-- ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
insert into capabilities (code, area, description) values
|
|
95
|
+
('company.export', 'company',
|
|
96
|
+
'Leave with the books: read every row of the company into an archive another installation can take in. It reads, and reads everything, which is why it is not part of any reading capability.')
|
|
97
|
+
on conflict (code) do nothing;
|
|
98
|
+
|
|
99
|
+
insert into role_capabilities (role, capability) values
|
|
100
|
+
('owner'::member_role, 'company.export'),
|
|
101
|
+
('client'::member_role, 'company.export')
|
|
102
|
+
on conflict do nothing;
|
|
103
|
+
|
|
104
|
+
-- ---------------------------------------------------------------------------
|
|
105
|
+
-- Versions
|
|
106
|
+
-- ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
create or replace function version_at_least(p_version text, p_floor text)
|
|
109
|
+
returns boolean
|
|
110
|
+
language sql
|
|
111
|
+
immutable
|
|
112
|
+
as $$
|
|
113
|
+
select string_to_array(p_version, '.')::integer[] >= string_to_array(p_floor, '.')::integer[];
|
|
114
|
+
$$;
|
|
115
|
+
|
|
116
|
+
comment on function version_at_least(text, text) is
|
|
117
|
+
'Whether a three-part version is at or above another, number by number: 0.10.0 is above 0.9.0, which a comparison of text gets wrong.';
|
|
118
|
+
|
|
119
|
+
-- ---------------------------------------------------------------------------
|
|
120
|
+
-- The registry
|
|
121
|
+
-- ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
create table company_archive_registry (
|
|
124
|
+
table_schema text not null default 'public',
|
|
125
|
+
table_name text not null,
|
|
126
|
+
disposition text not null,
|
|
127
|
+
reason text,
|
|
128
|
+
via_column text,
|
|
129
|
+
via_table text,
|
|
130
|
+
load_order integer,
|
|
131
|
+
primary key (table_schema, table_name),
|
|
132
|
+
constraint company_archive_registry_disposition
|
|
133
|
+
check (disposition in ('exported', 'excluded')),
|
|
134
|
+
constraint company_archive_registry_excluded_says_why
|
|
135
|
+
check (disposition = 'exported' or length(coalesce(reason, '')) >= 20),
|
|
136
|
+
constraint company_archive_registry_exported_has_an_order
|
|
137
|
+
check (disposition = 'excluded' or load_order is not null),
|
|
138
|
+
constraint company_archive_registry_via_is_a_pair
|
|
139
|
+
check ((via_column is null) = (via_table is null))
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
comment on table company_archive_registry is
|
|
143
|
+
'What an archive of one company does with each table of the socle that belongs to a company: `exported`, or `excluded` with the reason. A table of a company that is not named here stops every export. Modules answer for their own tables through `<schema>.archive_tables()`.';
|
|
144
|
+
comment on column company_archive_registry.reason is
|
|
145
|
+
'Why the table stays behind. Required on an excluded table; on an exported one, what a reader should know about it.';
|
|
146
|
+
comment on column company_archive_registry.via_column is
|
|
147
|
+
'For a table with no `company_id`: the column that leads to one that has it.';
|
|
148
|
+
comment on column company_archive_registry.via_table is
|
|
149
|
+
'The table `via_column` points at, schema included. It carries `company_id`.';
|
|
150
|
+
comment on column company_archive_registry.load_order is
|
|
151
|
+
'The order an import fills the tables in. A reference that points forward, or at its own table, is filled in a second pass and has to be nullable.';
|
|
152
|
+
|
|
153
|
+
alter table company_archive_registry enable row level security;
|
|
154
|
+
|
|
155
|
+
create policy company_archive_registry_select on company_archive_registry
|
|
156
|
+
for select to authenticated using (true);
|
|
157
|
+
|
|
158
|
+
comment on policy company_archive_registry_select on company_archive_registry is
|
|
159
|
+
'Reference data of the installation: which tables an archive carries is not a secret, and an export runs as its caller.';
|
|
160
|
+
|
|
161
|
+
revoke all on table company_archive_registry from public, anon;
|
|
162
|
+
grant select on table company_archive_registry to authenticated, service_role;
|
|
163
|
+
|
|
164
|
+
insert into company_archive_registry (table_name, disposition, load_order, via_column, via_table, reason) values
|
|
165
|
+
('companies', 'exported', 10, null, null, null),
|
|
166
|
+
('company_packs', 'exported', 20, null, null, null),
|
|
167
|
+
('company_modules', 'exported', 21, null, null, 'Which modules the company had on. `enabled_by` is a trace, not a user of the new installation.'),
|
|
168
|
+
('company_filing_periods', 'exported', 22, null, null, null),
|
|
169
|
+
('matching_settings', 'exported', 23, null, null, null),
|
|
170
|
+
('matching_sequences', 'exported', 24, null, null, 'The counter of the matching letters, so the next one continues the series.'),
|
|
171
|
+
('fiscal_years', 'exported', 30, null, null, null),
|
|
172
|
+
('accounts', 'exported', 31, null, null, null),
|
|
173
|
+
('bank_accounts', 'exported', 32, null, null, null),
|
|
174
|
+
('journals', 'exported', 33, null, null, null),
|
|
175
|
+
('journal_sequences', 'exported', 34, 'journal_id', 'public.journals', 'The counters of the numbered books, so the next entry continues the series.'),
|
|
176
|
+
('analytic_axes', 'exported', 35, null, null, null),
|
|
177
|
+
('analytic_values', 'exported', 36, null, null, null),
|
|
178
|
+
('taxes', 'exported', 37, null, null, null),
|
|
179
|
+
('tax_postings', 'exported', 38, null, null, null),
|
|
180
|
+
('contacts', 'exported', 39, null, null, null),
|
|
181
|
+
('contact_patterns', 'exported', 40, null, null, null),
|
|
182
|
+
('products', 'exported', 41, null, null, null),
|
|
183
|
+
('entries', 'exported', 50, null, null, null),
|
|
184
|
+
('entry_lines', 'exported', 51, null, null, null),
|
|
185
|
+
('entry_line_analytics', 'exported', 52, null, null, null),
|
|
186
|
+
('documents', 'exported', 53, null, null, null),
|
|
187
|
+
('document_lines', 'exported', 54, null, null, null),
|
|
188
|
+
('payments', 'exported', 55, null, null, null),
|
|
189
|
+
('reconciliations', 'exported', 56, null, null, null),
|
|
190
|
+
('bank_statements', 'exported', 57, null, null, null),
|
|
191
|
+
('bank_transactions', 'exported', 58, null, null, null),
|
|
192
|
+
('attachments', 'exported', 60, null, null, 'The rows, not the bytes: `storage_path`, `byte_size` and `checksum` say which files to carry across by other means.'),
|
|
193
|
+
('tax_filings', 'exported', 61, null, null, null),
|
|
194
|
+
('tax_filing_boxes', 'exported', 62, 'filing_id', 'public.tax_filings', 'The figures a declaration was frozen with.'),
|
|
195
|
+
('tax_filing_deposits', 'exported', 63, 'filing_id', 'public.tax_filings', 'The proof a declaration went: when, by which channel, under which reference, and what came back.'),
|
|
196
|
+
('audit_log', 'exported', 90, null, null, 'The trail of the company, with the identifiers of whoever acted kept as a trace. Row ids are local to an installation and are drawn again on arrival.'),
|
|
197
|
+
('api_keys', 'excluded', null, null, null, 'A credential of the installation it was issued in. A key that worked in two places would be a secret nobody can withdraw.'),
|
|
198
|
+
('company_invitations', 'excluded', null, null, null, 'A pending invitation is a token of this installation and the address of a person who has not agreed to be anywhere else.'),
|
|
199
|
+
('company_members', 'excluded', null, null, null, 'Who may read a company is decided where it lives. The users of one installation do not exist in another.'),
|
|
200
|
+
('document_shares', 'excluded', null, null, null, 'A shared link is a secret and points at this installation. It stays valid here and means nothing elsewhere.'),
|
|
201
|
+
('user_preferences', 'excluded', null, null, null, 'Belongs to a person, not to a company: it only names the company somebody opens first.')
|
|
202
|
+
on conflict (table_schema, table_name) do nothing;
|
|
203
|
+
|
|
204
|
+
-- ---------------------------------------------------------------------------
|
|
205
|
+
-- What the catalogue says belongs to a company
|
|
206
|
+
-- ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
create or replace function company_scoped_tables()
|
|
209
|
+
returns table (table_schema text, table_name text, has_company_id boolean)
|
|
210
|
+
language sql
|
|
211
|
+
stable
|
|
212
|
+
set search_path = public, pg_temp
|
|
213
|
+
as $$
|
|
214
|
+
with recursive reached (oid) as (
|
|
215
|
+
select 'public.companies'::regclass::oid
|
|
216
|
+
union
|
|
217
|
+
select c.conrelid
|
|
218
|
+
from pg_constraint c
|
|
219
|
+
join reached r on c.confrelid = r.oid
|
|
220
|
+
where c.contype = 'f'
|
|
221
|
+
),
|
|
222
|
+
named (oid) as (
|
|
223
|
+
select a.attrelid
|
|
224
|
+
from pg_attribute a
|
|
225
|
+
where a.attname = 'company_id' and a.attnum > 0 and not a.attisdropped
|
|
226
|
+
)
|
|
227
|
+
select n.nspname::text, k.relname::text,
|
|
228
|
+
exists (select 1 from named m where m.oid = k.oid)
|
|
229
|
+
from pg_class k
|
|
230
|
+
join pg_namespace n on n.oid = k.relnamespace
|
|
231
|
+
where k.relkind in ('r', 'p')
|
|
232
|
+
and n.nspname !~ '^pg_' and n.nspname <> 'information_schema'
|
|
233
|
+
and (k.oid in (select oid from reached) or k.oid in (select oid from named))
|
|
234
|
+
order by 1, 2;
|
|
235
|
+
$$;
|
|
236
|
+
|
|
237
|
+
comment on function company_scoped_tables() is
|
|
238
|
+
'Every table that belongs to a company, read from the catalogue: it carries `company_id`, or a foreign key leads from it to `companies`, directly or through another such table.';
|
|
239
|
+
|
|
240
|
+
create or replace function company_archive_tables()
|
|
241
|
+
returns table (
|
|
242
|
+
table_schema text,
|
|
243
|
+
table_name text,
|
|
244
|
+
disposition text,
|
|
245
|
+
reason text,
|
|
246
|
+
via_column text,
|
|
247
|
+
via_table text,
|
|
248
|
+
load_order integer
|
|
249
|
+
)
|
|
250
|
+
language plpgsql
|
|
251
|
+
stable
|
|
252
|
+
set search_path = public, pg_temp
|
|
253
|
+
as $$
|
|
254
|
+
declare
|
|
255
|
+
v_module record;
|
|
256
|
+
v_rank integer := 0;
|
|
257
|
+
begin
|
|
258
|
+
return query
|
|
259
|
+
select r.table_schema, r.table_name, r.disposition, r.reason,
|
|
260
|
+
r.via_column, r.via_table, r.load_order
|
|
261
|
+
from company_archive_registry r;
|
|
262
|
+
|
|
263
|
+
-- A module comes after the socle, always: it depends on `public` by foreign
|
|
264
|
+
-- key and the dependency points one way.
|
|
265
|
+
for v_module in select m.code, m.schema_name from modules m order by m.code loop
|
|
266
|
+
v_rank := v_rank + 1;
|
|
267
|
+
if to_regprocedure(format('%I.archive_tables()', v_module.schema_name)) is not null then
|
|
268
|
+
return query execute format(
|
|
269
|
+
'select %L::text, a.table_name, a.disposition, a.reason, a.via_column, a.via_table,
|
|
270
|
+
%s + a.load_order
|
|
271
|
+
from %I.archive_tables() a',
|
|
272
|
+
v_module.schema_name, v_rank * 1000, v_module.schema_name);
|
|
273
|
+
end if;
|
|
274
|
+
end loop;
|
|
275
|
+
end;
|
|
276
|
+
$$;
|
|
277
|
+
|
|
278
|
+
comment on function company_archive_tables() is
|
|
279
|
+
'The registry of the socle and what every installed module answers through `<schema>.archive_tables()`, as one list. Modules load after the socle.';
|
|
280
|
+
|
|
281
|
+
create or replace function company_archive_unclassified()
|
|
282
|
+
returns table (table_schema text, table_name text, problem text)
|
|
283
|
+
language sql
|
|
284
|
+
stable
|
|
285
|
+
set search_path = public, pg_temp
|
|
286
|
+
as $$
|
|
287
|
+
select s.table_schema, s.table_name,
|
|
288
|
+
'belongs to a company and is neither exported nor excluded'::text
|
|
289
|
+
from company_scoped_tables() s
|
|
290
|
+
where not exists (select 1 from company_archive_tables() t
|
|
291
|
+
where t.table_schema = s.table_schema and t.table_name = s.table_name)
|
|
292
|
+
union all
|
|
293
|
+
select t.table_schema, t.table_name, 'is classified and does not exist'::text
|
|
294
|
+
from company_archive_tables() t
|
|
295
|
+
where to_regclass(format('%I.%I', t.table_schema, t.table_name)) is null
|
|
296
|
+
union all
|
|
297
|
+
select t.table_schema, t.table_name,
|
|
298
|
+
'is exported, carries no company_id and names no table that leads to one'::text
|
|
299
|
+
from company_archive_tables() t
|
|
300
|
+
join company_scoped_tables() s
|
|
301
|
+
on s.table_schema = t.table_schema and s.table_name = t.table_name
|
|
302
|
+
where t.disposition = 'exported' and not s.has_company_id and t.via_table is null
|
|
303
|
+
and (t.table_schema, t.table_name) <> ('public', 'companies')
|
|
304
|
+
order by 1, 2;
|
|
305
|
+
$$;
|
|
306
|
+
|
|
307
|
+
comment on function company_archive_unclassified() is
|
|
308
|
+
'What stands between this installation and an honest archive: a table of a company nobody classified, a classification of a table that is gone, an exported table with no way to a company. Empty is the only answer an export accepts.';
|
|
309
|
+
|
|
310
|
+
create or replace function company_archive_predicate(p_schema text, p_table text, p_alias text)
|
|
311
|
+
returns text
|
|
312
|
+
language plpgsql
|
|
313
|
+
stable
|
|
314
|
+
set search_path = public, pg_temp
|
|
315
|
+
as $$
|
|
316
|
+
declare
|
|
317
|
+
v_row record;
|
|
318
|
+
v_target text;
|
|
319
|
+
begin
|
|
320
|
+
if (p_schema, p_table) = ('public', 'companies') then
|
|
321
|
+
return format('%I.id = $1', p_alias);
|
|
322
|
+
end if;
|
|
323
|
+
|
|
324
|
+
select * into v_row from company_archive_tables() t
|
|
325
|
+
where t.table_schema = p_schema and t.table_name = p_table;
|
|
326
|
+
if not found then
|
|
327
|
+
raise exception 'unknown_table: %.% is not a table an archive knows', p_schema, p_table;
|
|
328
|
+
end if;
|
|
329
|
+
|
|
330
|
+
if v_row.via_table is null then
|
|
331
|
+
return format('%I.company_id = $1', p_alias);
|
|
332
|
+
end if;
|
|
333
|
+
|
|
334
|
+
-- The column the foreign key lands on, from the catalogue rather than from
|
|
335
|
+
-- the habit of calling it `id`.
|
|
336
|
+
select a.attname into v_target
|
|
337
|
+
from pg_constraint c
|
|
338
|
+
join pg_attribute f on f.attrelid = c.conrelid and f.attnum = c.conkey[1]
|
|
339
|
+
join pg_attribute a on a.attrelid = c.confrelid and a.attnum = c.confkey[1]
|
|
340
|
+
where c.contype = 'f'
|
|
341
|
+
and c.conrelid = to_regclass(format('%I.%I', p_schema, p_table))
|
|
342
|
+
and c.confrelid = to_regclass(v_row.via_table)
|
|
343
|
+
and cardinality(c.conkey) = 1
|
|
344
|
+
and f.attname = v_row.via_column;
|
|
345
|
+
if v_target is null then
|
|
346
|
+
raise exception 'unknown_table: %.% says it reaches a company through % to %, and no foreign key does that',
|
|
347
|
+
p_schema, p_table, v_row.via_column, v_row.via_table;
|
|
348
|
+
end if;
|
|
349
|
+
|
|
350
|
+
return format('%I.%I in (select via.%I from %s via where via.company_id = $1)',
|
|
351
|
+
p_alias, v_row.via_column, v_target, to_regclass(v_row.via_table)::text);
|
|
352
|
+
end;
|
|
353
|
+
$$;
|
|
354
|
+
|
|
355
|
+
comment on function company_archive_predicate(text, text, text) is
|
|
356
|
+
'The SQL condition that keeps the rows of one company in one table, with the company as `$1`. One place, read by the export, by the count and by the checks of an import.';
|
|
357
|
+
|
|
358
|
+
-- ---------------------------------------------------------------------------
|
|
359
|
+
-- Leaving
|
|
360
|
+
-- ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
create or replace function company_archive_row_count(p_company_id uuid, p_table text)
|
|
363
|
+
returns bigint
|
|
364
|
+
language plpgsql
|
|
365
|
+
stable
|
|
366
|
+
security definer
|
|
367
|
+
set search_path = public, pg_temp
|
|
368
|
+
as $$
|
|
369
|
+
declare
|
|
370
|
+
v_schema text := split_part(p_table, '.', 1);
|
|
371
|
+
v_name text := split_part(p_table, '.', 2);
|
|
372
|
+
v_count bigint;
|
|
373
|
+
begin
|
|
374
|
+
if has_capability(p_company_id, 'company.export') is not true then
|
|
375
|
+
raise exception 'not_allowed: counting the rows of this company needs company.export'
|
|
376
|
+
using errcode = '42501';
|
|
377
|
+
end if;
|
|
378
|
+
if not exists (select 1 from company_archive_tables() t
|
|
379
|
+
where t.table_schema = v_schema and t.table_name = v_name
|
|
380
|
+
and t.disposition = 'exported') then
|
|
381
|
+
raise exception 'unknown_table: % is not a table an archive carries', p_table;
|
|
382
|
+
end if;
|
|
383
|
+
|
|
384
|
+
execute format('select count(*) from %I.%I t where %s',
|
|
385
|
+
v_schema, v_name, company_archive_predicate(v_schema, v_name, 't'))
|
|
386
|
+
into v_count using p_company_id;
|
|
387
|
+
return v_count;
|
|
388
|
+
end;
|
|
389
|
+
$$;
|
|
390
|
+
|
|
391
|
+
comment on function company_archive_row_count(uuid, text) is
|
|
392
|
+
'How many rows one table holds for one company, whatever the caller may read of them. Definer, guarded by company.export, and it answers a number and nothing else: it is what lets an export notice that row level security handed it less than there is.';
|
|
393
|
+
|
|
394
|
+
create or replace function note_company_export(p_company_id uuid)
|
|
395
|
+
returns void
|
|
396
|
+
language plpgsql
|
|
397
|
+
security definer
|
|
398
|
+
set search_path = public, pg_temp
|
|
399
|
+
as $$
|
|
400
|
+
declare
|
|
401
|
+
v_name text;
|
|
402
|
+
begin
|
|
403
|
+
if has_capability(p_company_id, 'company.export') is not true then
|
|
404
|
+
raise exception 'not_allowed: leaving with the books of this company needs company.export'
|
|
405
|
+
using errcode = '42501';
|
|
406
|
+
end if;
|
|
407
|
+
|
|
408
|
+
select c.name into v_name from companies c where c.id = p_company_id;
|
|
409
|
+
perform audit_record(p_company_id, 'companies', p_company_id, coalesce(v_name, p_company_id::text),
|
|
410
|
+
'update', 'company_exported', null,
|
|
411
|
+
jsonb_build_object('socle_version', ekwo_schema_version()));
|
|
412
|
+
end;
|
|
413
|
+
$$;
|
|
414
|
+
|
|
415
|
+
comment on function note_company_export(uuid) is
|
|
416
|
+
'Writes `company_exported` on the audit trail. Called by `export_company()` and by `ekwo company export` before they read. A record of the act, not a control: a member can always read, table by table, what they may read. Definer because nobody writes the trail; guarded by company.export.';
|
|
417
|
+
|
|
418
|
+
create or replace function assert_may_export_company(p_company_id uuid)
|
|
419
|
+
returns void
|
|
420
|
+
language plpgsql
|
|
421
|
+
stable
|
|
422
|
+
set search_path = public, pg_temp
|
|
423
|
+
as $$
|
|
424
|
+
begin
|
|
425
|
+
if has_capability(p_company_id, 'company.export') is not true then
|
|
426
|
+
raise exception 'not_allowed: leaving with the books of this company needs company.export'
|
|
427
|
+
using errcode = '42501';
|
|
428
|
+
end if;
|
|
429
|
+
-- Under row level security, or not at all. A role that bypasses it — the
|
|
430
|
+
-- backend role of a Supabase project, the owner of the database — reads
|
|
431
|
+
-- every company at once, so "what the caller may read" means nothing for it
|
|
432
|
+
-- and the comparison below would compare a number with itself. Whoever holds
|
|
433
|
+
-- such a role has `pg_dump`; what they do not get is an archive that says a
|
|
434
|
+
-- person took it.
|
|
435
|
+
if exists (select 1 from pg_roles r where r.rolname = current_user and (r.rolbypassrls or r.rolsuper)) then
|
|
436
|
+
raise exception 'not_allowed: an archive is read under row level security, and % is not subject to it', current_user
|
|
437
|
+
using errcode = '42501';
|
|
438
|
+
end if;
|
|
439
|
+
end;
|
|
440
|
+
$$;
|
|
441
|
+
|
|
442
|
+
comment on function assert_may_export_company(uuid) is
|
|
443
|
+
'The two conditions of leaving: company.export on the company, and a caller that row level security applies to. Invoker, so `current_user` is the role that asked.';
|
|
444
|
+
|
|
445
|
+
create or replace function export_company_table(p_company_id uuid, p_table text)
|
|
446
|
+
returns setof jsonb
|
|
447
|
+
language plpgsql
|
|
448
|
+
stable
|
|
449
|
+
set search_path = public, pg_temp
|
|
450
|
+
set timezone = 'UTC'
|
|
451
|
+
as $$
|
|
452
|
+
declare
|
|
453
|
+
v_schema text := split_part(p_table, '.', 1);
|
|
454
|
+
v_name text := split_part(p_table, '.', 2);
|
|
455
|
+
v_problem record;
|
|
456
|
+
v_where text;
|
|
457
|
+
v_decimals text;
|
|
458
|
+
v_order text;
|
|
459
|
+
v_seen bigint;
|
|
460
|
+
v_held bigint;
|
|
461
|
+
begin
|
|
462
|
+
perform assert_may_export_company(p_company_id);
|
|
463
|
+
|
|
464
|
+
select * into v_problem from company_archive_unclassified() limit 1;
|
|
465
|
+
if found then
|
|
466
|
+
raise exception 'unclassified_table: %.% % — an archive that might be incomplete is not written. Classify it in company_archive_registry, or in the archive_tables() of its module.',
|
|
467
|
+
v_problem.table_schema, v_problem.table_name, v_problem.problem
|
|
468
|
+
using errcode = '55006';
|
|
469
|
+
end if;
|
|
470
|
+
|
|
471
|
+
if not exists (select 1 from company_archive_tables() t
|
|
472
|
+
where t.table_schema = v_schema and t.table_name = v_name
|
|
473
|
+
and t.disposition = 'exported') then
|
|
474
|
+
raise exception 'unknown_table: % is not a table an archive carries', p_table;
|
|
475
|
+
end if;
|
|
476
|
+
|
|
477
|
+
v_where := company_archive_predicate(v_schema, v_name, 't');
|
|
478
|
+
|
|
479
|
+
execute format('select count(*) from %I.%I t where %s', v_schema, v_name, v_where)
|
|
480
|
+
into v_seen using p_company_id;
|
|
481
|
+
v_held := company_archive_row_count(p_company_id, p_table);
|
|
482
|
+
if v_seen <> v_held then
|
|
483
|
+
raise exception 'export_incomplete: % holds % rows for this company and you may read % of them. An archive is whole or it is not written: ask for the reading capability you lack — or, for the table of a module, for the module to be enabled.',
|
|
484
|
+
p_table, v_held, v_seen
|
|
485
|
+
using errcode = '42501';
|
|
486
|
+
end if;
|
|
487
|
+
|
|
488
|
+
-- Decimals leave as text. `12.50` is a valid JSON number and most readers
|
|
489
|
+
-- hand it back as a float; an archive of a ledger must not depend on which.
|
|
490
|
+
select coalesce(' || jsonb_build_object(' ||
|
|
491
|
+
string_agg(format('%L, t.%I::text', a.attname, a.attname), ', ' order by a.attnum) || ')', '')
|
|
492
|
+
into v_decimals
|
|
493
|
+
from pg_attribute a
|
|
494
|
+
where a.attrelid = to_regclass(format('%I.%I', v_schema, v_name))
|
|
495
|
+
and a.attnum > 0 and not a.attisdropped
|
|
496
|
+
and a.atttypid = 'numeric'::regtype;
|
|
497
|
+
|
|
498
|
+
select string_agg(format('t.%I', a.attname), ', ' order by array_position(i.indkey::smallint[], a.attnum))
|
|
499
|
+
into v_order
|
|
500
|
+
from pg_index i
|
|
501
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
|
|
502
|
+
where i.indrelid = to_regclass(format('%I.%I', v_schema, v_name)) and i.indisprimary;
|
|
503
|
+
if v_order is null then
|
|
504
|
+
raise exception 'unknown_table: % has no primary key, so its rows have no order to be written in', p_table;
|
|
505
|
+
end if;
|
|
506
|
+
|
|
507
|
+
return query execute format('select to_jsonb(t)%s from %I.%I t where %s order by %s',
|
|
508
|
+
v_decimals, v_schema, v_name, v_where, v_order)
|
|
509
|
+
using p_company_id;
|
|
510
|
+
end;
|
|
511
|
+
$$;
|
|
512
|
+
|
|
513
|
+
comment on function export_company_table(uuid, text) is
|
|
514
|
+
'The rows of one table for one company, one JSON object each, in primary key order: decimals as text, timestamps in UTC. Runs as its caller, needs company.export, refuses when the caller may read fewer rows than the table holds, and refuses while any table of a company is unclassified. Stable, so it reads the snapshot of the statement that calls it: called table after table, it needs a repeatable read transaction around the calls for the tables to agree with each other and with the manifest — which is what the CLI opens — or use export_company(), which is one statement.';
|
|
515
|
+
|
|
516
|
+
create or replace function export_company_manifest(p_company_id uuid)
|
|
517
|
+
returns jsonb
|
|
518
|
+
language plpgsql
|
|
519
|
+
stable
|
|
520
|
+
set search_path = public, pg_temp
|
|
521
|
+
set timezone = 'UTC'
|
|
522
|
+
as $$
|
|
523
|
+
declare
|
|
524
|
+
v_company companies%rowtype;
|
|
525
|
+
v_table record;
|
|
526
|
+
v_tables jsonb := '[]'::jsonb;
|
|
527
|
+
v_rows bigint;
|
|
528
|
+
v_checksum text;
|
|
529
|
+
begin
|
|
530
|
+
perform assert_may_export_company(p_company_id);
|
|
531
|
+
|
|
532
|
+
select * into v_company from companies c where c.id = p_company_id;
|
|
533
|
+
if not found then
|
|
534
|
+
raise exception 'unknown_company: % is not a company you may read', p_company_id
|
|
535
|
+
using errcode = 'P0002';
|
|
536
|
+
end if;
|
|
537
|
+
|
|
538
|
+
for v_table in
|
|
539
|
+
select t.table_schema || '.' || t.table_name as name
|
|
540
|
+
from company_archive_tables() t
|
|
541
|
+
where t.disposition = 'exported'
|
|
542
|
+
order by t.load_order, t.table_schema, t.table_name
|
|
543
|
+
loop
|
|
544
|
+
select count(*),
|
|
545
|
+
encode(sha256(convert_to(coalesce(string_agg(x.r::text || E'\n', '' order by x.n), ''), 'UTF8')), 'hex')
|
|
546
|
+
into v_rows, v_checksum
|
|
547
|
+
from export_company_table(p_company_id, v_table.name) with ordinality as x(r, n);
|
|
548
|
+
|
|
549
|
+
v_tables := v_tables || jsonb_build_object(
|
|
550
|
+
'name', v_table.name,
|
|
551
|
+
'file', 'data/' || v_table.name || '.jsonl',
|
|
552
|
+
'rows', v_rows,
|
|
553
|
+
'sha256', v_checksum);
|
|
554
|
+
end loop;
|
|
555
|
+
|
|
556
|
+
return jsonb_build_object(
|
|
557
|
+
'format', 'ekwo.company-archive',
|
|
558
|
+
'format_version', 1,
|
|
559
|
+
'exported_at', to_jsonb(now()),
|
|
560
|
+
'exported_by', auth.uid(),
|
|
561
|
+
'socle_version', ekwo_schema_version(),
|
|
562
|
+
'origin_instance', (select i.instance_id from instance i),
|
|
563
|
+
'company', jsonb_build_object(
|
|
564
|
+
'id', v_company.id,
|
|
565
|
+
'name', v_company.name,
|
|
566
|
+
'country', v_company.country,
|
|
567
|
+
'fiscal_country', v_company.fiscal_country,
|
|
568
|
+
'currency_code', v_company.currency_code),
|
|
569
|
+
'packs', coalesce((
|
|
570
|
+
select jsonb_agg(jsonb_build_object('country', p.country, 'version', p.version, 'chart_code', p.chart_code)
|
|
571
|
+
order by p.country)
|
|
572
|
+
from company_packs p where p.company_id = p_company_id), '[]'::jsonb),
|
|
573
|
+
'modules', coalesce((
|
|
574
|
+
select jsonb_agg(jsonb_build_object('code', m.code, 'version', m.version) order by m.code)
|
|
575
|
+
from company_modules cm join modules m on m.code = cm.module_code
|
|
576
|
+
where cm.company_id = p_company_id), '[]'::jsonb),
|
|
577
|
+
'tables', v_tables,
|
|
578
|
+
'excluded', coalesce((
|
|
579
|
+
select jsonb_agg(jsonb_build_object('name', t.table_schema || '.' || t.table_name, 'reason', t.reason)
|
|
580
|
+
order by t.table_schema, t.table_name)
|
|
581
|
+
from company_archive_tables() t where t.disposition = 'excluded'), '[]'::jsonb),
|
|
582
|
+
'files', jsonb_build_object(
|
|
583
|
+
'transported', false,
|
|
584
|
+
'list', coalesce((
|
|
585
|
+
select jsonb_agg(jsonb_build_object(
|
|
586
|
+
'attachment_id', a.id,
|
|
587
|
+
'storage_path', a.storage_path,
|
|
588
|
+
'file_name', a.file_name,
|
|
589
|
+
'mime_type', a.mime_type,
|
|
590
|
+
'byte_size', a.byte_size,
|
|
591
|
+
'checksum', a.checksum) order by a.id)
|
|
592
|
+
from attachments a where a.company_id = p_company_id), '[]'::jsonb)));
|
|
593
|
+
end;
|
|
594
|
+
$$;
|
|
595
|
+
|
|
596
|
+
comment on function export_company_manifest(uuid) is
|
|
597
|
+
'What an archive of this company is: the format and its version, the socle, the packs and the modules an installation needs to take it in, every table with its row count and the sha256 of its rows, the tables left behind with the reason, and the list of the files the attachments point at — which the archive does not carry.';
|
|
598
|
+
|
|
599
|
+
create or replace function export_company_tables(p_company_id uuid)
|
|
600
|
+
returns jsonb
|
|
601
|
+
language sql
|
|
602
|
+
stable
|
|
603
|
+
set search_path = public, pg_temp
|
|
604
|
+
set timezone = 'UTC'
|
|
605
|
+
as $$
|
|
606
|
+
select coalesce(jsonb_object_agg(
|
|
607
|
+
t.table_schema || '.' || t.table_name,
|
|
608
|
+
coalesce((select jsonb_agg(x.r order by x.n)
|
|
609
|
+
from export_company_table(p_company_id, t.table_schema || '.' || t.table_name)
|
|
610
|
+
with ordinality as x(r, n)),
|
|
611
|
+
'[]'::jsonb)), '{}'::jsonb)
|
|
612
|
+
from company_archive_tables() t
|
|
613
|
+
where t.disposition = 'exported';
|
|
614
|
+
$$;
|
|
615
|
+
|
|
616
|
+
comment on function export_company_tables(uuid) is
|
|
617
|
+
'Every exported table of one company as one object, keyed by table name. The `tables` half of export_company().';
|
|
618
|
+
|
|
619
|
+
create or replace function export_company(p_company_id uuid)
|
|
620
|
+
returns jsonb
|
|
621
|
+
language plpgsql
|
|
622
|
+
set search_path = public, pg_temp
|
|
623
|
+
set timezone = 'UTC'
|
|
624
|
+
as $$
|
|
625
|
+
declare
|
|
626
|
+
v_archive jsonb;
|
|
627
|
+
begin
|
|
628
|
+
perform note_company_export(p_company_id);
|
|
629
|
+
-- One statement, so one snapshot: a volatile function sees a new one at each
|
|
630
|
+
-- statement, and an archive whose manifest was computed a moment before its
|
|
631
|
+
-- rows, or whose lines were read a moment after their entries, is one that
|
|
632
|
+
-- fails on arrival — after the company has left. The two readers are stable
|
|
633
|
+
-- and share the snapshot of this select.
|
|
634
|
+
select jsonb_build_object('manifest', export_company_manifest(p_company_id),
|
|
635
|
+
'tables', export_company_tables(p_company_id))
|
|
636
|
+
into v_archive;
|
|
637
|
+
return v_archive;
|
|
638
|
+
end;
|
|
639
|
+
$$;
|
|
640
|
+
|
|
641
|
+
comment on function export_company(uuid) is
|
|
642
|
+
'The whole archive as one document, read in one snapshot: `manifest`, and `tables` keyed by table name. It is what `import_company()` takes, and it writes `company_exported` on the audit trail. A large company is better read table by table, which is what the CLI does; this is the same rows in one answer.';
|
|
643
|
+
|
|
644
|
+
-- ---------------------------------------------------------------------------
|
|
645
|
+
-- Arriving
|
|
646
|
+
-- ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
create or replace function import_company(p_archive jsonb, p_owner_user_id uuid default null)
|
|
649
|
+
returns jsonb
|
|
650
|
+
language plpgsql
|
|
651
|
+
security definer
|
|
652
|
+
set search_path = public, pg_temp
|
|
653
|
+
as $$
|
|
654
|
+
declare
|
|
655
|
+
v_manifest jsonb := p_archive -> 'manifest';
|
|
656
|
+
v_tables jsonb := coalesce(p_archive -> 'tables', '{}'::jsonb);
|
|
657
|
+
v_company uuid;
|
|
658
|
+
v_owner uuid := coalesce(p_owner_user_id, auth.uid());
|
|
659
|
+
v_item jsonb;
|
|
660
|
+
v_problem record;
|
|
661
|
+
v_table record;
|
|
662
|
+
v_class regclass;
|
|
663
|
+
v_rows jsonb;
|
|
664
|
+
v_count bigint;
|
|
665
|
+
v_checksum text;
|
|
666
|
+
v_columns text[];
|
|
667
|
+
v_deferred text[];
|
|
668
|
+
v_unknown text;
|
|
669
|
+
v_select text;
|
|
670
|
+
v_silenced regclass[] := '{}';
|
|
671
|
+
v_second jsonb := '[]'::jsonb;
|
|
672
|
+
v_were_off jsonb := '[]'::jsonb;
|
|
673
|
+
v_pass jsonb;
|
|
674
|
+
v_fk record;
|
|
675
|
+
v_total bigint := 0;
|
|
676
|
+
v_name text;
|
|
677
|
+
v_found text;
|
|
678
|
+
begin
|
|
679
|
+
-- The guard, first and in the open: the two callers who may create a company
|
|
680
|
+
-- here, and nobody else. A definer function that checks nobody is how two
|
|
681
|
+
-- doors were found open on 18 September.
|
|
682
|
+
--
|
|
683
|
+
-- `is not true`, not `not`: a helper that answers NULL turns `if not … and
|
|
684
|
+
-- not …` into an `if NULL`, which does not raise. `is_installer()` did, until
|
|
685
|
+
-- `20260918140000`, for the backend role through the API. The guard is
|
|
686
|
+
-- written so that it holds whatever the helpers answer.
|
|
687
|
+
if is_installer() is not true and is_instance_admin() is not true then
|
|
688
|
+
raise exception 'not_instance_admin: taking a company into this installation is an instance-level act'
|
|
689
|
+
using errcode = '42501';
|
|
690
|
+
end if;
|
|
691
|
+
|
|
692
|
+
if v_manifest is null or v_manifest ->> 'format' is distinct from 'ekwo.company-archive' then
|
|
693
|
+
raise exception 'not_an_archive: this document does not say it is an ekwo.company-archive';
|
|
694
|
+
end if;
|
|
695
|
+
if v_manifest ->> 'format_version' is distinct from '1' then
|
|
696
|
+
raise exception 'unknown_archive_version: this installation reads version 1 of the format, and the archive says %',
|
|
697
|
+
coalesce(v_manifest ->> 'format_version', 'nothing');
|
|
698
|
+
end if;
|
|
699
|
+
if not coalesce(version_at_least(ekwo_schema_version(), v_manifest ->> 'socle_version'), false) then
|
|
700
|
+
raise exception 'socle_too_old: the archive was written by socle % and this installation is %. Run `ekwo migrate` first.',
|
|
701
|
+
v_manifest ->> 'socle_version', ekwo_schema_version()
|
|
702
|
+
using errcode = '55006';
|
|
703
|
+
end if;
|
|
704
|
+
|
|
705
|
+
-- What the company needs is read from its rows as well as from the manifest:
|
|
706
|
+
-- the manifest is a summary somebody could have edited, the rows are what
|
|
707
|
+
-- will be here afterwards.
|
|
708
|
+
for v_item in
|
|
709
|
+
select jsonb_build_object('country', e ->> 'country', 'version', e ->> 'version')
|
|
710
|
+
from jsonb_array_elements(coalesce(v_tables -> 'public.company_packs', '[]'::jsonb)) e
|
|
711
|
+
union
|
|
712
|
+
select jsonb_build_object('country', m ->> 'country', 'version', m ->> 'version')
|
|
713
|
+
from jsonb_array_elements(coalesce(v_manifest -> 'packs', '[]'::jsonb)) m
|
|
714
|
+
loop
|
|
715
|
+
select p.version into v_found from country_packs p where p.country = v_item ->> 'country';
|
|
716
|
+
if not found then
|
|
717
|
+
raise exception 'pack_missing: the company holds the % pack and this installation does not', v_item ->> 'country'
|
|
718
|
+
using errcode = '55006';
|
|
719
|
+
end if;
|
|
720
|
+
if not coalesce(version_at_least(v_found, v_item ->> 'version'), false) then
|
|
721
|
+
raise exception 'pack_too_old: the company is on version % of the % pack and this installation holds %',
|
|
722
|
+
v_item ->> 'version', v_item ->> 'country', v_found
|
|
723
|
+
using errcode = '55006';
|
|
724
|
+
end if;
|
|
725
|
+
end loop;
|
|
726
|
+
|
|
727
|
+
for v_item in
|
|
728
|
+
select jsonb_build_object('code', m ->> 'code', 'version', m ->> 'version')
|
|
729
|
+
from jsonb_array_elements(coalesce(v_manifest -> 'modules', '[]'::jsonb)) m
|
|
730
|
+
union
|
|
731
|
+
select jsonb_build_object('code', e ->> 'module_code', 'version', null)
|
|
732
|
+
from jsonb_array_elements(coalesce(v_tables -> 'public.company_modules', '[]'::jsonb)) e
|
|
733
|
+
loop
|
|
734
|
+
select m.version into v_found from modules m where m.code = v_item ->> 'code';
|
|
735
|
+
if not found then
|
|
736
|
+
raise exception 'module_missing: the company uses the % module and this installation does not carry it', v_item ->> 'code'
|
|
737
|
+
using errcode = '55006';
|
|
738
|
+
end if;
|
|
739
|
+
if v_item ->> 'version' is not null and not version_at_least(v_found, v_item ->> 'version') then
|
|
740
|
+
raise exception 'module_too_old: the company used version % of the % module and this installation holds %',
|
|
741
|
+
v_item ->> 'version', v_item ->> 'code', v_found
|
|
742
|
+
using errcode = '55006';
|
|
743
|
+
end if;
|
|
744
|
+
end loop;
|
|
745
|
+
|
|
746
|
+
v_company := (v_manifest #>> '{company,id}')::uuid;
|
|
747
|
+
if v_company is null then
|
|
748
|
+
raise exception 'not_an_archive: the manifest names no company';
|
|
749
|
+
end if;
|
|
750
|
+
-- Identifiers are kept, so a company is here or it is not. This is also what
|
|
751
|
+
-- a second run of the same import meets.
|
|
752
|
+
if exists (select 1 from companies c where c.id = v_company) then
|
|
753
|
+
raise exception 'company_already_here: % is a company of this installation. An import never merges: a company arrives whole, once.', v_company
|
|
754
|
+
using errcode = '23505';
|
|
755
|
+
end if;
|
|
756
|
+
|
|
757
|
+
select * into v_problem from company_archive_unclassified() limit 1;
|
|
758
|
+
if found then
|
|
759
|
+
raise exception 'unclassified_table: %.% % — this installation cannot say what an archive is made of',
|
|
760
|
+
v_problem.table_schema, v_problem.table_name, v_problem.problem
|
|
761
|
+
using errcode = '55006';
|
|
762
|
+
end if;
|
|
763
|
+
|
|
764
|
+
-- The manifest and the tables say the same thing, and every table is one
|
|
765
|
+
-- this installation exports itself.
|
|
766
|
+
for v_name in select jsonb_object_keys(v_tables) loop
|
|
767
|
+
if not exists (select 1 from jsonb_array_elements(v_manifest -> 'tables') m where m ->> 'name' = v_name) then
|
|
768
|
+
raise exception 'archive_corrupt: the archive carries rows of % and its manifest does not list it', v_name;
|
|
769
|
+
end if;
|
|
770
|
+
end loop;
|
|
771
|
+
for v_item in select jsonb_array_elements(coalesce(v_manifest -> 'tables', '[]'::jsonb)) loop
|
|
772
|
+
v_name := v_item ->> 'name';
|
|
773
|
+
if not exists (select 1 from company_archive_tables() t
|
|
774
|
+
where t.table_schema || '.' || t.table_name = v_name and t.disposition = 'exported') then
|
|
775
|
+
raise exception 'unknown_table: the archive carries %, which this installation does not know as a table of a company', v_name
|
|
776
|
+
using errcode = '55006';
|
|
777
|
+
end if;
|
|
778
|
+
v_rows := coalesce(v_tables -> v_name, '[]'::jsonb);
|
|
779
|
+
select count(*),
|
|
780
|
+
encode(sha256(convert_to(coalesce(string_agg(x.r::text || E'\n', '' order by x.n), ''), 'UTF8')), 'hex')
|
|
781
|
+
into v_count, v_checksum
|
|
782
|
+
from jsonb_array_elements(v_rows) with ordinality as x(r, n);
|
|
783
|
+
if v_count is distinct from (v_item ->> 'rows')::bigint or v_checksum is distinct from v_item ->> 'sha256' then
|
|
784
|
+
raise exception 'archive_corrupt: % does not match its manifest (% rows against %, or another checksum)',
|
|
785
|
+
v_name, v_count, v_item ->> 'rows';
|
|
786
|
+
end if;
|
|
787
|
+
end loop;
|
|
788
|
+
|
|
789
|
+
if jsonb_array_length(coalesce(v_tables -> 'public.companies', '[]'::jsonb)) <> 1 then
|
|
790
|
+
raise exception 'foreign_row: an archive holds one company, and this one holds % rows of public.companies',
|
|
791
|
+
jsonb_array_length(coalesce(v_tables -> 'public.companies', '[]'::jsonb));
|
|
792
|
+
end if;
|
|
793
|
+
|
|
794
|
+
-- First pass: the rows, in the order the registry names.
|
|
795
|
+
for v_table in
|
|
796
|
+
select t.table_schema, t.table_name, t.load_order,
|
|
797
|
+
t.table_schema || '.' || t.table_name as name
|
|
798
|
+
from company_archive_tables() t
|
|
799
|
+
where t.disposition = 'exported'
|
|
800
|
+
order by t.load_order, t.table_schema, t.table_name
|
|
801
|
+
loop
|
|
802
|
+
v_rows := coalesce(v_tables -> v_table.name, '[]'::jsonb);
|
|
803
|
+
continue when jsonb_array_length(v_rows) = 0;
|
|
804
|
+
v_class := to_regclass(format('%I.%I', v_table.table_schema, v_table.table_name));
|
|
805
|
+
v_total := v_total + jsonb_array_length(v_rows);
|
|
806
|
+
|
|
807
|
+
-- A column this installation does not have is data it would drop in
|
|
808
|
+
-- silence. It refuses instead.
|
|
809
|
+
select k.key into v_unknown
|
|
810
|
+
from (select distinct jsonb_object_keys(e) as key from jsonb_array_elements(v_rows) e) k
|
|
811
|
+
where not exists (select 1 from pg_attribute a
|
|
812
|
+
where a.attrelid = v_class and a.attname = k.key
|
|
813
|
+
and a.attnum > 0 and not a.attisdropped)
|
|
814
|
+
limit 1;
|
|
815
|
+
if v_unknown is not null then
|
|
816
|
+
raise exception 'unknown_column: %.% is in the archive and not in this installation', v_table.name, v_unknown
|
|
817
|
+
using errcode = '55006';
|
|
818
|
+
end if;
|
|
819
|
+
|
|
820
|
+
-- Every row has the columns of the first. The list of columns written is
|
|
821
|
+
-- read from one row; a key that only a later row carries would be dropped
|
|
822
|
+
-- in silence, and one it lacks would be written as null over a default.
|
|
823
|
+
if exists (select 1 from jsonb_array_elements(v_rows) e
|
|
824
|
+
where (select array_agg(k order by k) from jsonb_object_keys(e) k)
|
|
825
|
+
is distinct from
|
|
826
|
+
(select array_agg(k order by k) from jsonb_object_keys(v_rows -> 0) k)) then
|
|
827
|
+
raise exception 'archive_corrupt: the rows of % do not all have the same columns', v_table.name;
|
|
828
|
+
end if;
|
|
829
|
+
|
|
830
|
+
-- Every row says which company it is of, and it is the one that arrives.
|
|
831
|
+
if v_table.name = 'public.companies' then
|
|
832
|
+
if jsonb_array_length(v_rows) <> 1 or (v_rows -> 0 ->> 'id') is distinct from v_company::text then
|
|
833
|
+
raise exception 'foreign_row: an archive holds one company, the one its manifest names';
|
|
834
|
+
end if;
|
|
835
|
+
elsif exists (select 1 from pg_attribute a
|
|
836
|
+
where a.attrelid = v_class and a.attname = 'company_id' and not a.attisdropped) then
|
|
837
|
+
if exists (select 1 from jsonb_array_elements(v_rows) e
|
|
838
|
+
where e ->> 'company_id' is distinct from v_company::text) then
|
|
839
|
+
raise exception 'foreign_row: % holds a row of another company than the one the archive names', v_table.name;
|
|
840
|
+
end if;
|
|
841
|
+
end if;
|
|
842
|
+
|
|
843
|
+
-- A reference to a table that loads later, or to its own table, waits for
|
|
844
|
+
-- the second pass. Derived from the foreign keys, so a new one needs no
|
|
845
|
+
-- line here; it has to be nullable, and a test says so before a user does.
|
|
846
|
+
select coalesce(array_agg(distinct a.attname), '{}') into v_deferred
|
|
847
|
+
from pg_constraint c
|
|
848
|
+
join unnest(c.conkey) as k(attnum) on true
|
|
849
|
+
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum
|
|
850
|
+
join pg_class rc on rc.oid = c.confrelid
|
|
851
|
+
join pg_namespace rn on rn.oid = rc.relnamespace
|
|
852
|
+
join company_archive_tables() r
|
|
853
|
+
on r.table_schema = rn.nspname and r.table_name = rc.relname and r.disposition = 'exported'
|
|
854
|
+
where c.contype = 'f' and c.conrelid = v_class
|
|
855
|
+
and r.load_order >= v_table.load_order
|
|
856
|
+
and a.attname <> 'company_id';
|
|
857
|
+
|
|
858
|
+
if exists (select 1 from pg_attribute a
|
|
859
|
+
where a.attrelid = v_class and a.attname = any (v_deferred) and a.attnotnull) then
|
|
860
|
+
raise exception 'cannot_order: % holds a required reference to a table that loads after it', v_table.name;
|
|
861
|
+
end if;
|
|
862
|
+
|
|
863
|
+
-- What is written: every column of the table the archive names, but the
|
|
864
|
+
-- generated ones, which compute themselves, and an identity, which is
|
|
865
|
+
-- local to an installation and is drawn again.
|
|
866
|
+
select array_agg(a.attname order by a.attnum) into v_columns
|
|
867
|
+
from pg_attribute a
|
|
868
|
+
where a.attrelid = v_class and a.attnum > 0 and not a.attisdropped
|
|
869
|
+
and a.attgenerated = '' and a.attidentity = ''
|
|
870
|
+
and (v_rows -> 0) ? a.attname;
|
|
871
|
+
|
|
872
|
+
select string_agg(case when c = any (v_deferred) then format('null as %I', c) else format('r.%I', c) end, ', ')
|
|
873
|
+
into v_select
|
|
874
|
+
from unnest(v_columns) as c;
|
|
875
|
+
|
|
876
|
+
-- The guards of a table are written for a person booking one thing at a
|
|
877
|
+
-- time, in order, today. They are switched off on the tables being filled
|
|
878
|
+
-- and nowhere else, inside this transaction, under a lock that makes every
|
|
879
|
+
-- other writer of the table wait — and only where there is one to switch
|
|
880
|
+
-- off, so `audit_log`, whose only trigger refuses updates and deletes,
|
|
881
|
+
-- is never touched.
|
|
882
|
+
if exists (select 1 from pg_trigger g
|
|
883
|
+
where g.tgrelid = v_class and not g.tgisinternal
|
|
884
|
+
and ((g.tgtype & 4) <> 0 or ((g.tgtype & 16) <> 0 and cardinality(v_deferred) > 0))) then
|
|
885
|
+
-- A trigger an operator had set otherwise — off, or firing on a replica
|
|
886
|
+
-- — is put back the way it was.
|
|
887
|
+
v_were_off := v_were_off || coalesce((
|
|
888
|
+
select jsonb_agg(jsonb_build_object(
|
|
889
|
+
'class', v_class::text, 'name', g.tgname,
|
|
890
|
+
'verb', case g.tgenabled when 'D' then 'disable trigger'
|
|
891
|
+
when 'R' then 'enable replica trigger'
|
|
892
|
+
else 'enable always trigger' end))
|
|
893
|
+
from pg_trigger g
|
|
894
|
+
where g.tgrelid = v_class and not g.tgisinternal and g.tgenabled <> 'O'), '[]'::jsonb);
|
|
895
|
+
execute format('alter table %s disable trigger user', v_class);
|
|
896
|
+
v_silenced := v_silenced || v_class;
|
|
897
|
+
end if;
|
|
898
|
+
|
|
899
|
+
-- In the order of the archive, so that an identity drawn again — the ids
|
|
900
|
+
-- of the trail — follows the order the rows were written in.
|
|
901
|
+
execute format('insert into %s (%s) select %s from jsonb_array_elements($1) with ordinality as e(v, n)
|
|
902
|
+
cross join lateral jsonb_populate_record(null::%s, e.v) as r order by e.n',
|
|
903
|
+
v_class, (select string_agg(format('%I', c), ', ') from unnest(v_columns) c),
|
|
904
|
+
v_select, v_class)
|
|
905
|
+
using v_rows;
|
|
906
|
+
|
|
907
|
+
if cardinality(v_deferred) > 0 then
|
|
908
|
+
v_second := v_second || jsonb_build_object('table', v_table.name, 'class', v_class::text,
|
|
909
|
+
'columns', to_jsonb(v_deferred));
|
|
910
|
+
end if;
|
|
911
|
+
end loop;
|
|
912
|
+
|
|
913
|
+
-- Second pass: the references that pointed forward.
|
|
914
|
+
for v_pass in select jsonb_array_elements(v_second) loop
|
|
915
|
+
select array_agg(c) into v_deferred
|
|
916
|
+
from jsonb_array_elements_text(v_pass -> 'columns') c
|
|
917
|
+
where (v_tables -> (v_pass ->> 'table') -> 0) ? c;
|
|
918
|
+
continue when v_deferred is null;
|
|
919
|
+
execute format(
|
|
920
|
+
'update %s t set %s from jsonb_populate_recordset(null::%s, $1) r where %s and (%s)',
|
|
921
|
+
v_pass ->> 'class',
|
|
922
|
+
(select string_agg(format('%I = r.%I', c, c), ', ') from unnest(v_deferred) c),
|
|
923
|
+
v_pass ->> 'class',
|
|
924
|
+
-- By the primary key, read from the catalogue rather than assumed to be `id`.
|
|
925
|
+
(select string_agg(format('t.%I = r.%I', a.attname, a.attname), ' and ')
|
|
926
|
+
from pg_index i
|
|
927
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
|
|
928
|
+
where i.indrelid = (v_pass ->> 'class')::regclass and i.indisprimary),
|
|
929
|
+
(select string_agg(format('r.%I is not null', c), ' or ') from unnest(v_deferred) c))
|
|
930
|
+
using v_tables -> (v_pass ->> 'table');
|
|
931
|
+
end loop;
|
|
932
|
+
|
|
933
|
+
foreach v_class in array v_silenced loop
|
|
934
|
+
execute format('alter table %s enable trigger user', v_class);
|
|
935
|
+
end loop;
|
|
936
|
+
for v_pass in select jsonb_array_elements(v_were_off) loop
|
|
937
|
+
execute format('alter table %s %s %I', v_pass ->> 'class', v_pass ->> 'verb', v_pass ->> 'name');
|
|
938
|
+
end loop;
|
|
939
|
+
|
|
940
|
+
-- What the triggers would have guaranteed, asked of the result.
|
|
941
|
+
|
|
942
|
+
-- 1. Everything that arrived is of this company: a row that hung itself on
|
|
943
|
+
-- a parent of another company is missing from this count.
|
|
944
|
+
for v_table in
|
|
945
|
+
select t.table_schema, t.table_name, t.table_schema || '.' || t.table_name as name
|
|
946
|
+
from company_archive_tables() t where t.disposition = 'exported'
|
|
947
|
+
loop
|
|
948
|
+
execute format('select count(*) from %I.%I t where %s', v_table.table_schema, v_table.table_name,
|
|
949
|
+
company_archive_predicate(v_table.table_schema, v_table.table_name, 't'))
|
|
950
|
+
into v_count using v_company;
|
|
951
|
+
if v_count <> jsonb_array_length(coalesce(v_tables -> v_table.name, '[]'::jsonb)) then
|
|
952
|
+
raise exception 'foreign_row: % holds % rows of this company after the import and the archive carried %',
|
|
953
|
+
v_table.name, v_count, jsonb_array_length(coalesce(v_tables -> v_table.name, '[]'::jsonb));
|
|
954
|
+
end if;
|
|
955
|
+
end loop;
|
|
956
|
+
|
|
957
|
+
-- 2. No reference leaves the company. Most foreign keys of the schema carry
|
|
958
|
+
-- `company_id` and refuse this themselves; the ones that do not are why
|
|
959
|
+
-- this is asked of all of them.
|
|
960
|
+
for v_fk in
|
|
961
|
+
select c.conrelid::regclass as child, c.confrelid::regclass as parent,
|
|
962
|
+
cn.nspname as child_schema, ck.relname as child_table,
|
|
963
|
+
pn.nspname as parent_schema, pk.relname as parent_table,
|
|
964
|
+
ca.attname as child_column, pa.attname as parent_column
|
|
965
|
+
from pg_constraint c
|
|
966
|
+
join pg_class ck on ck.oid = c.conrelid
|
|
967
|
+
join pg_namespace cn on cn.oid = ck.relnamespace
|
|
968
|
+
join pg_class pk on pk.oid = c.confrelid
|
|
969
|
+
join pg_namespace pn on pn.oid = pk.relnamespace
|
|
970
|
+
join pg_attribute ca on ca.attrelid = c.conrelid and ca.attnum = c.conkey[1]
|
|
971
|
+
join pg_attribute pa on pa.attrelid = c.confrelid and pa.attnum = c.confkey[1]
|
|
972
|
+
join company_archive_tables() ct
|
|
973
|
+
on ct.table_schema = cn.nspname and ct.table_name = ck.relname and ct.disposition = 'exported'
|
|
974
|
+
join company_archive_tables() pt
|
|
975
|
+
on pt.table_schema = pn.nspname and pt.table_name = pk.relname and pt.disposition = 'exported'
|
|
976
|
+
where c.contype = 'f' and cardinality(c.conkey) = 1
|
|
977
|
+
loop
|
|
978
|
+
execute format(
|
|
979
|
+
'select count(*) from %s t join %s p on p.%I = t.%I where %s and not (%s)',
|
|
980
|
+
v_fk.child, v_fk.parent, v_fk.parent_column, v_fk.child_column,
|
|
981
|
+
company_archive_predicate(v_fk.child_schema, v_fk.child_table, 't'),
|
|
982
|
+
company_archive_predicate(v_fk.parent_schema, v_fk.parent_table, 'p'))
|
|
983
|
+
into v_count using v_company;
|
|
984
|
+
if v_count > 0 then
|
|
985
|
+
raise exception 'foreign_row: %.% points at a row of another company in % (% rows)',
|
|
986
|
+
v_fk.child, v_fk.child_column, v_fk.parent, v_count;
|
|
987
|
+
end if;
|
|
988
|
+
end loop;
|
|
989
|
+
|
|
990
|
+
-- 3. The ledger: an entry agrees with its lines, and a posted one balances.
|
|
991
|
+
select e.number into v_found
|
|
992
|
+
from entries e
|
|
993
|
+
left join lateral (select coalesce(sum(l.debit), 0) as debit, coalesce(sum(l.credit), 0) as credit
|
|
994
|
+
from entry_lines l where l.entry_id = e.id) s on true
|
|
995
|
+
where e.company_id = v_company
|
|
996
|
+
and (e.total_debit <> s.debit or e.total_credit <> s.credit
|
|
997
|
+
or (e.state = 'posted' and s.debit <> s.credit))
|
|
998
|
+
limit 1;
|
|
999
|
+
if found then
|
|
1000
|
+
raise exception 'unbalanced_entry: entry % does not balance, or does not agree with its lines', coalesce(v_found, '(no number)');
|
|
1001
|
+
end if;
|
|
1002
|
+
|
|
1003
|
+
-- 4. The matching: what a line says is matched is what its matchings add up to.
|
|
1004
|
+
select l.id::text into v_found
|
|
1005
|
+
from entry_lines l
|
|
1006
|
+
where l.company_id = v_company
|
|
1007
|
+
and l.matched_amount <> coalesce((select sum(r.amount) from reconciliations r
|
|
1008
|
+
where r.debit_line_id = l.id or r.credit_line_id = l.id), 0)
|
|
1009
|
+
limit 1;
|
|
1010
|
+
if found then
|
|
1011
|
+
raise exception 'matching_mismatch: line % says it is matched for another amount than its matchings add up to', v_found;
|
|
1012
|
+
end if;
|
|
1013
|
+
|
|
1014
|
+
-- 5. The numbered books: no counter is behind a number already used, or the
|
|
1015
|
+
-- next entry would take a number that exists.
|
|
1016
|
+
select e.number into v_found
|
|
1017
|
+
from entries e
|
|
1018
|
+
join journals j on j.id = e.journal_id
|
|
1019
|
+
cross join lateral numbering_rules(v_company) n
|
|
1020
|
+
where e.company_id = v_company and e.number is not null
|
|
1021
|
+
and number_counter(n.number_format, e.number) is not null
|
|
1022
|
+
and number_counter(n.number_format, e.number) > coalesce((
|
|
1023
|
+
select s.last_number from journal_sequences s
|
|
1024
|
+
where s.journal_id = j.id
|
|
1025
|
+
and s.year = case when n.number_format ~ '\{(YYYY|YY)\}'
|
|
1026
|
+
then extract(year from e.entry_date)::smallint
|
|
1027
|
+
else 0::smallint end), 0)
|
|
1028
|
+
limit 1;
|
|
1029
|
+
if found then
|
|
1030
|
+
raise exception 'counter_behind: entry % carries a number its journal has not counted up to; the next entry would collide', v_found;
|
|
1031
|
+
end if;
|
|
1032
|
+
|
|
1033
|
+
-- The letters of the matching, the same way.
|
|
1034
|
+
select l.matching_number into v_found
|
|
1035
|
+
from entry_lines l
|
|
1036
|
+
where l.company_id = v_company and l.matching_number ~ '[0-9]+$'
|
|
1037
|
+
and substring(l.matching_number from '[0-9]+$')::bigint
|
|
1038
|
+
> coalesce((select s.last_number from matching_sequences s where s.company_id = v_company), 0)
|
|
1039
|
+
limit 1;
|
|
1040
|
+
if found then
|
|
1041
|
+
raise exception 'counter_behind: the matching letter % is ahead of the counter of the company; the next matching would reuse a letter', v_found;
|
|
1042
|
+
end if;
|
|
1043
|
+
|
|
1044
|
+
-- 6. The financial years do not overlap: every date belongs to one year or
|
|
1045
|
+
-- to none, which is what a lock and a closing are read against.
|
|
1046
|
+
select x.name into v_found
|
|
1047
|
+
from fiscal_years x
|
|
1048
|
+
join fiscal_years y on y.company_id = x.company_id and y.id <> x.id
|
|
1049
|
+
and daterange(x.start_date, x.end_date, '[]') && daterange(y.start_date, y.end_date, '[]')
|
|
1050
|
+
where x.company_id = v_company
|
|
1051
|
+
limit 1;
|
|
1052
|
+
if found then
|
|
1053
|
+
raise exception 'overlapping_years: the financial year % overlaps another one of the same company', v_found;
|
|
1054
|
+
end if;
|
|
1055
|
+
|
|
1056
|
+
-- The first member. The people of the other installation did not travel.
|
|
1057
|
+
if v_owner is not null then
|
|
1058
|
+
insert into company_members (company_id, user_id, role)
|
|
1059
|
+
values (v_company, v_owner, 'owner')
|
|
1060
|
+
on conflict (company_id, user_id) do nothing;
|
|
1061
|
+
end if;
|
|
1062
|
+
|
|
1063
|
+
-- Where this installation's own testimony starts: everything on the trail
|
|
1064
|
+
-- of this company before this row is what the archive said.
|
|
1065
|
+
perform audit_record(v_company, 'companies', v_company, v_manifest #>> '{company,name}',
|
|
1066
|
+
'insert', 'company_imported', null,
|
|
1067
|
+
jsonb_build_object(
|
|
1068
|
+
'origin_instance', v_manifest -> 'origin_instance',
|
|
1069
|
+
'exported_at', v_manifest -> 'exported_at',
|
|
1070
|
+
'exported_by', v_manifest -> 'exported_by',
|
|
1071
|
+
'socle_version', v_manifest -> 'socle_version',
|
|
1072
|
+
'rows', v_total));
|
|
1073
|
+
|
|
1074
|
+
return jsonb_build_object(
|
|
1075
|
+
'company_id', v_company,
|
|
1076
|
+
'name', v_manifest #>> '{company,name}',
|
|
1077
|
+
'rows', v_total,
|
|
1078
|
+
'owner', v_owner,
|
|
1079
|
+
'tables', (select jsonb_object_agg(m ->> 'name', (m ->> 'rows')::bigint)
|
|
1080
|
+
from jsonb_array_elements(v_manifest -> 'tables') m),
|
|
1081
|
+
'files_to_carry', jsonb_array_length(coalesce(v_manifest #> '{files,list}', '[]'::jsonb)));
|
|
1082
|
+
end;
|
|
1083
|
+
$$;
|
|
1084
|
+
|
|
1085
|
+
comment on function import_company(jsonb, uuid) is
|
|
1086
|
+
'Takes in the archive `export_company()` wrote: one company, whole, with its identifiers, its numbers, its locks and its trail. The installer or an administrator of the installation only. Rows are inserted with the user triggers of the filled tables off for the length of the transaction, foreign keys on, and the result is checked — ownership of every row, no reference into another company, balance, matching, counters — before anything stays. A company already here is refused. `p_owner_user_id`, or the caller, becomes its first owner.';
|
|
1087
|
+
|
|
1088
|
+
-- ---------------------------------------------------------------------------
|
|
1089
|
+
-- Grants. Every function is reachable by a signed-in user and guarded inside,
|
|
1090
|
+
-- which is the doctrine of 20260914151207.
|
|
1091
|
+
-- ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
revoke execute on function version_at_least(text, text) from public, anon;
|
|
1094
|
+
revoke execute on function company_scoped_tables() from public, anon;
|
|
1095
|
+
revoke execute on function company_archive_tables() from public, anon;
|
|
1096
|
+
revoke execute on function company_archive_unclassified() from public, anon;
|
|
1097
|
+
revoke execute on function company_archive_predicate(text, text, text) from public, anon;
|
|
1098
|
+
revoke execute on function company_archive_row_count(uuid, text) from public, anon;
|
|
1099
|
+
revoke execute on function note_company_export(uuid) from public, anon;
|
|
1100
|
+
revoke execute on function assert_may_export_company(uuid) from public, anon;
|
|
1101
|
+
revoke execute on function export_company_table(uuid, text) from public, anon;
|
|
1102
|
+
revoke execute on function export_company_manifest(uuid) from public, anon;
|
|
1103
|
+
revoke execute on function export_company_tables(uuid) from public, anon;
|
|
1104
|
+
revoke execute on function export_company(uuid) from public, anon;
|
|
1105
|
+
revoke execute on function import_company(jsonb, uuid) from public, anon;
|
|
1106
|
+
|
|
1107
|
+
grant execute on function version_at_least(text, text) to authenticated, service_role;
|
|
1108
|
+
grant execute on function company_scoped_tables() to authenticated, service_role;
|
|
1109
|
+
grant execute on function company_archive_tables() to authenticated, service_role;
|
|
1110
|
+
grant execute on function company_archive_unclassified() to authenticated, service_role;
|
|
1111
|
+
grant execute on function company_archive_predicate(text, text, text) to authenticated, service_role;
|
|
1112
|
+
grant execute on function company_archive_row_count(uuid, text) to authenticated, service_role;
|
|
1113
|
+
grant execute on function note_company_export(uuid) to authenticated, service_role;
|
|
1114
|
+
grant execute on function assert_may_export_company(uuid) to authenticated, service_role;
|
|
1115
|
+
grant execute on function export_company_table(uuid, text) to authenticated, service_role;
|
|
1116
|
+
grant execute on function export_company_manifest(uuid) to authenticated, service_role;
|
|
1117
|
+
grant execute on function export_company_tables(uuid) to authenticated, service_role;
|
|
1118
|
+
grant execute on function export_company(uuid) to authenticated, service_role;
|
|
1119
|
+
grant execute on function import_company(jsonb, uuid) to authenticated, service_role;
|