ekwo-os 0.11.0 → 0.11.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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion": "0.11.
|
|
3
|
-
"migration": "
|
|
2
|
+
"schemaVersion": "0.11.1",
|
|
3
|
+
"migration": "20261007200000",
|
|
4
4
|
"socle": {
|
|
5
5
|
"schema": "public",
|
|
6
6
|
"tables": [
|
|
@@ -3844,6 +3844,10 @@
|
|
|
3844
3844
|
"name": "audit_state_change",
|
|
3845
3845
|
"arguments": ""
|
|
3846
3846
|
},
|
|
3847
|
+
{
|
|
3848
|
+
"name": "authenticated_statement_timeout",
|
|
3849
|
+
"arguments": ""
|
|
3850
|
+
},
|
|
3847
3851
|
{
|
|
3848
3852
|
"name": "auto_settle",
|
|
3849
3853
|
"arguments": "p_company_id uuid, p_from date, p_to date, p_apply boolean"
|
|
@@ -3872,6 +3876,10 @@
|
|
|
3872
3876
|
"name": "bank_statements_refresh_balance",
|
|
3873
3877
|
"arguments": ""
|
|
3874
3878
|
},
|
|
3879
|
+
{
|
|
3880
|
+
"name": "caller_companies",
|
|
3881
|
+
"arguments": ""
|
|
3882
|
+
},
|
|
3875
3883
|
{
|
|
3876
3884
|
"name": "can_write_company",
|
|
3877
3885
|
"arguments": "p_company_id uuid"
|
|
@@ -3924,10 +3932,22 @@
|
|
|
3924
3932
|
"name": "company_archive_predicate",
|
|
3925
3933
|
"arguments": "p_schema text, p_table text, p_alias text"
|
|
3926
3934
|
},
|
|
3935
|
+
{
|
|
3936
|
+
"name": "company_archive_predicate_via",
|
|
3937
|
+
"arguments": "p_schema text, p_table text, p_via_column text, p_via_table text, p_alias text"
|
|
3938
|
+
},
|
|
3927
3939
|
{
|
|
3928
3940
|
"name": "company_archive_row_count",
|
|
3929
3941
|
"arguments": "p_company_id uuid, p_table text"
|
|
3930
3942
|
},
|
|
3943
|
+
{
|
|
3944
|
+
"name": "company_archive_row_counts",
|
|
3945
|
+
"arguments": "p_company_id uuid"
|
|
3946
|
+
},
|
|
3947
|
+
{
|
|
3948
|
+
"name": "company_archive_rows_query",
|
|
3949
|
+
"arguments": "p_schema text, p_table text, p_where text"
|
|
3950
|
+
},
|
|
3931
3951
|
{
|
|
3932
3952
|
"name": "company_archive_tables",
|
|
3933
3953
|
"arguments": ""
|
|
@@ -4160,6 +4180,10 @@
|
|
|
4160
4180
|
"name": "export_company",
|
|
4161
4181
|
"arguments": "p_company_id uuid"
|
|
4162
4182
|
},
|
|
4183
|
+
{
|
|
4184
|
+
"name": "export_company_archive",
|
|
4185
|
+
"arguments": "p_company_id uuid, p_with_rows boolean"
|
|
4186
|
+
},
|
|
4163
4187
|
{
|
|
4164
4188
|
"name": "export_company_manifest",
|
|
4165
4189
|
"arguments": "p_company_id uuid"
|
|
@@ -7200,6 +7224,17 @@
|
|
|
7200
7224
|
"authenticated": [],
|
|
7201
7225
|
"service_role": []
|
|
7202
7226
|
},
|
|
7227
|
+
{
|
|
7228
|
+
"name": "authenticated_statement_timeout",
|
|
7229
|
+
"arguments": "",
|
|
7230
|
+
"anon": [],
|
|
7231
|
+
"authenticated": [
|
|
7232
|
+
"execute"
|
|
7233
|
+
],
|
|
7234
|
+
"service_role": [
|
|
7235
|
+
"execute"
|
|
7236
|
+
]
|
|
7237
|
+
},
|
|
7203
7238
|
{
|
|
7204
7239
|
"name": "auto_settle",
|
|
7205
7240
|
"arguments": "p_company_id uuid, p_from date, p_to date, p_apply boolean",
|
|
@@ -7265,6 +7300,17 @@
|
|
|
7265
7300
|
"authenticated": [],
|
|
7266
7301
|
"service_role": []
|
|
7267
7302
|
},
|
|
7303
|
+
{
|
|
7304
|
+
"name": "caller_companies",
|
|
7305
|
+
"arguments": "",
|
|
7306
|
+
"anon": [],
|
|
7307
|
+
"authenticated": [
|
|
7308
|
+
"execute"
|
|
7309
|
+
],
|
|
7310
|
+
"service_role": [
|
|
7311
|
+
"execute"
|
|
7312
|
+
]
|
|
7313
|
+
},
|
|
7268
7314
|
{
|
|
7269
7315
|
"name": "can_write_company",
|
|
7270
7316
|
"arguments": "p_company_id uuid",
|
|
@@ -7398,6 +7444,17 @@
|
|
|
7398
7444
|
"execute"
|
|
7399
7445
|
]
|
|
7400
7446
|
},
|
|
7447
|
+
{
|
|
7448
|
+
"name": "company_archive_predicate_via",
|
|
7449
|
+
"arguments": "p_schema text, p_table text, p_via_column text, p_via_table text, p_alias text",
|
|
7450
|
+
"anon": [],
|
|
7451
|
+
"authenticated": [
|
|
7452
|
+
"execute"
|
|
7453
|
+
],
|
|
7454
|
+
"service_role": [
|
|
7455
|
+
"execute"
|
|
7456
|
+
]
|
|
7457
|
+
},
|
|
7401
7458
|
{
|
|
7402
7459
|
"name": "company_archive_row_count",
|
|
7403
7460
|
"arguments": "p_company_id uuid, p_table text",
|
|
@@ -7409,6 +7466,28 @@
|
|
|
7409
7466
|
"execute"
|
|
7410
7467
|
]
|
|
7411
7468
|
},
|
|
7469
|
+
{
|
|
7470
|
+
"name": "company_archive_row_counts",
|
|
7471
|
+
"arguments": "p_company_id uuid",
|
|
7472
|
+
"anon": [],
|
|
7473
|
+
"authenticated": [
|
|
7474
|
+
"execute"
|
|
7475
|
+
],
|
|
7476
|
+
"service_role": [
|
|
7477
|
+
"execute"
|
|
7478
|
+
]
|
|
7479
|
+
},
|
|
7480
|
+
{
|
|
7481
|
+
"name": "company_archive_rows_query",
|
|
7482
|
+
"arguments": "p_schema text, p_table text, p_where text",
|
|
7483
|
+
"anon": [],
|
|
7484
|
+
"authenticated": [
|
|
7485
|
+
"execute"
|
|
7486
|
+
],
|
|
7487
|
+
"service_role": [
|
|
7488
|
+
"execute"
|
|
7489
|
+
]
|
|
7490
|
+
},
|
|
7412
7491
|
{
|
|
7413
7492
|
"name": "company_archive_tables",
|
|
7414
7493
|
"arguments": "",
|
|
@@ -7970,6 +8049,17 @@
|
|
|
7970
8049
|
"execute"
|
|
7971
8050
|
]
|
|
7972
8051
|
},
|
|
8052
|
+
{
|
|
8053
|
+
"name": "export_company_archive",
|
|
8054
|
+
"arguments": "p_company_id uuid, p_with_rows boolean",
|
|
8055
|
+
"anon": [],
|
|
8056
|
+
"authenticated": [
|
|
8057
|
+
"execute"
|
|
8058
|
+
],
|
|
8059
|
+
"service_role": [
|
|
8060
|
+
"execute"
|
|
8061
|
+
]
|
|
8062
|
+
},
|
|
7973
8063
|
{
|
|
7974
8064
|
"name": "export_company_manifest",
|
|
7975
8065
|
"arguments": "p_company_id uuid",
|
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- An export fits the time a key's request is given
|
|
3
|
+
-- ---------------------------------------------------------------------------
|
|
4
|
+
-- A nightly copy of a company calls `export_company()` over the API, with the
|
|
5
|
+
-- publishable key and a machine key in `X-Ekwo-Api-Key`. Measured on a real
|
|
6
|
+
-- 0.10.0 installation, every such copy failed with `canceling statement due
|
|
7
|
+
-- to statement timeout`, for two reasons that only meet over HTTP.
|
|
8
|
+
--
|
|
9
|
+
-- 1. **The request kept the clock of `anon`.** PostgREST arms the
|
|
10
|
+
-- `statement_timeout` of the role it starts the request as — `anon`,
|
|
11
|
+
-- three seconds on a hosted project. `ekwo_pre_request()` moves the
|
|
12
|
+
-- request onto `authenticated` once the key is presented, and the clock
|
|
13
|
+
-- stayed the anonymous one: a member who signed in had eight seconds for
|
|
14
|
+
-- the same call, a key three. The pre-request now also takes the
|
|
15
|
+
-- `statement_timeout` configured for `authenticated` — read from
|
|
16
|
+
-- `pg_db_role_setting` by `authenticated_statement_timeout()`, the
|
|
17
|
+
-- setting for this database first and the one for every database after
|
|
18
|
+
-- it, which is the order PostgreSQL applies them in. Where none is configured the request keeps the clock it has:
|
|
19
|
+
-- no number is written here.
|
|
20
|
+
--
|
|
21
|
+
-- 2. **An export did the work of a table forty-seven times, twice.**
|
|
22
|
+
-- `export_company()` read the manifest and then the rows, and each half
|
|
23
|
+
-- called `export_company_table()` once per table. Each call checked the
|
|
24
|
+
-- right to export, swept the catalogue for an unclassified table
|
|
25
|
+
-- (`company_archive_unclassified()`, about 20 ms on its own), listed the
|
|
26
|
+
-- tables of an archive, built the condition of the table from that list
|
|
27
|
+
-- again, and counted the rows twice — about 30 ms before a single row
|
|
28
|
+
-- was read, on an empty table as on a full one. The rows of every table
|
|
29
|
+
-- were then read twice: once to checksum them, once to write them.
|
|
30
|
+
--
|
|
31
|
+
-- `export_company_archive()` is the export, once: the right, the sweep
|
|
32
|
+
-- and the list of tables are asked once per archive, the rows the
|
|
33
|
+
-- administrator holds are counted once for every table by
|
|
34
|
+
-- `company_archive_row_counts()`, and each table is read once — its
|
|
35
|
+
-- count, both checksums and its rows come from the same pass.
|
|
36
|
+
-- `export_company()`, `export_company_manifest()` and
|
|
37
|
+
-- `export_company_tables()` are that function, whole or in half.
|
|
38
|
+
--
|
|
39
|
+
-- The bytes do not move. The rows are written by one query, which
|
|
40
|
+
-- `export_company_table()` — the reader of the command line, table by
|
|
41
|
+
-- table — now builds from the same place (`company_archive_rows_query()`),
|
|
42
|
+
-- the order is the one it always was, and the checksums are computed
|
|
43
|
+
-- over the same text. `tests/export_fits_a_key_request.test.ts` exports
|
|
44
|
+
-- a furnished company with the functions of 0.11.0 and with these, in
|
|
45
|
+
-- one transaction, and compares the two documents byte for byte.
|
|
46
|
+
--
|
|
47
|
+
-- Still one snapshot: the function is stable, and `export_company()`
|
|
48
|
+
-- calls it from one statement.
|
|
49
|
+
--
|
|
50
|
+
-- 3. **The trail asked who reads it once per row.** `audit_log_select` now
|
|
51
|
+
-- asks it once per statement, the way the policies of `20260918141627`
|
|
52
|
+
-- do: `caller_companies()` lists the companies the caller is on, and the
|
|
53
|
+
-- policy compares `company_id` with that list.
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
-- ---------------------------------------------------------------------------
|
|
57
|
+
-- 1. The pre-request takes the clock of the role it gives
|
|
58
|
+
-- ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
create or replace function authenticated_statement_timeout()
|
|
61
|
+
returns text
|
|
62
|
+
language sql
|
|
63
|
+
stable
|
|
64
|
+
security definer
|
|
65
|
+
set search_path = public, pg_temp
|
|
66
|
+
as $$
|
|
67
|
+
-- The setting for this database wins over the one for every database, as
|
|
68
|
+
-- it does when PostgreSQL applies them at login.
|
|
69
|
+
select substr(c.setting, length('statement_timeout=') + 1)
|
|
70
|
+
from pg_db_role_setting s
|
|
71
|
+
join pg_roles r on r.oid = s.setrole
|
|
72
|
+
cross join lateral unnest(s.setconfig) as c(setting)
|
|
73
|
+
where r.rolname = 'authenticated'
|
|
74
|
+
and s.setdatabase in (0, (select d.oid from pg_database d where d.datname = current_database()))
|
|
75
|
+
and c.setting like 'statement\_timeout=%'
|
|
76
|
+
order by s.setdatabase desc
|
|
77
|
+
limit 1;
|
|
78
|
+
$$;
|
|
79
|
+
|
|
80
|
+
comment on function authenticated_statement_timeout() is
|
|
81
|
+
'The statement_timeout configured for the role `authenticated` on this database, or NULL when none is. Definer because the catalogue of role settings is not every role''s to read; it answers that one value and nothing else.';
|
|
82
|
+
|
|
83
|
+
create or replace function ekwo_pre_request()
|
|
84
|
+
returns void
|
|
85
|
+
language plpgsql
|
|
86
|
+
set search_path = public, pg_temp
|
|
87
|
+
as $$
|
|
88
|
+
declare
|
|
89
|
+
v_headers json := nullif(current_setting('request.headers', true), '')::json;
|
|
90
|
+
v_secret text := nullif(v_headers ->> 'x-ekwo-api-key', '');
|
|
91
|
+
v_timeout text;
|
|
92
|
+
begin
|
|
93
|
+
if v_secret is null then
|
|
94
|
+
return;
|
|
95
|
+
end if;
|
|
96
|
+
|
|
97
|
+
-- Raises on a key that is unknown, withdrawn or expired, and the raise fails
|
|
98
|
+
-- the request. Nothing below this line runs for a key that is not one.
|
|
99
|
+
perform present_api_key(v_secret);
|
|
100
|
+
|
|
101
|
+
-- Off `anon`, which holds no privilege on any table, and onto the role whose
|
|
102
|
+
-- grants row level security is written against. `auth.uid()` stays null.
|
|
103
|
+
perform set_config('role', 'authenticated', true);
|
|
104
|
+
|
|
105
|
+
-- And onto its clock. PostgREST armed the one of the role it started the
|
|
106
|
+
-- request as; the work that follows is the work of `authenticated`, and is
|
|
107
|
+
-- given its time.
|
|
108
|
+
v_timeout := authenticated_statement_timeout();
|
|
109
|
+
if v_timeout is not null then
|
|
110
|
+
perform set_config('statement_timeout', v_timeout, true);
|
|
111
|
+
end if;
|
|
112
|
+
end;
|
|
113
|
+
$$;
|
|
114
|
+
|
|
115
|
+
comment on function ekwo_pre_request() is
|
|
116
|
+
'Called by PostgREST at the start of every request (pgrst.db_pre_request). Presents the key in X-Ekwo-Api-Key when there is one, then moves the request off `anon` so that row level security decides instead of the grants, and gives it the statement_timeout configured for `authenticated`, when one is. No header, no effect.';
|
|
117
|
+
|
|
118
|
+
-- ---------------------------------------------------------------------------
|
|
119
|
+
-- 2. An export does the work of a table once
|
|
120
|
+
-- ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
create or replace function company_archive_predicate_via(
|
|
123
|
+
p_schema text,
|
|
124
|
+
p_table text,
|
|
125
|
+
p_via_column text,
|
|
126
|
+
p_via_table text,
|
|
127
|
+
p_alias text
|
|
128
|
+
)
|
|
129
|
+
returns text
|
|
130
|
+
language plpgsql
|
|
131
|
+
stable
|
|
132
|
+
set search_path = public, pg_temp
|
|
133
|
+
as $$
|
|
134
|
+
declare
|
|
135
|
+
v_target text;
|
|
136
|
+
begin
|
|
137
|
+
if (p_schema, p_table) = ('public', 'companies') then
|
|
138
|
+
return format('%I.id = $1', p_alias);
|
|
139
|
+
end if;
|
|
140
|
+
|
|
141
|
+
if p_via_table is null then
|
|
142
|
+
return format('%I.company_id = $1', p_alias);
|
|
143
|
+
end if;
|
|
144
|
+
|
|
145
|
+
-- The column the foreign key lands on, from the catalogue rather than from
|
|
146
|
+
-- the habit of calling it `id`.
|
|
147
|
+
select a.attname into v_target
|
|
148
|
+
from pg_constraint c
|
|
149
|
+
join pg_attribute f on f.attrelid = c.conrelid and f.attnum = c.conkey[1]
|
|
150
|
+
join pg_attribute a on a.attrelid = c.confrelid and a.attnum = c.confkey[1]
|
|
151
|
+
where c.contype = 'f'
|
|
152
|
+
and c.conrelid = to_regclass(format('%I.%I', p_schema, p_table))
|
|
153
|
+
and c.confrelid = to_regclass(p_via_table)
|
|
154
|
+
and cardinality(c.conkey) = 1
|
|
155
|
+
and f.attname = p_via_column;
|
|
156
|
+
if v_target is null then
|
|
157
|
+
raise exception 'unknown_table: %.% says it reaches a company through % to %, and no foreign key does that',
|
|
158
|
+
p_schema, p_table, p_via_column, p_via_table;
|
|
159
|
+
end if;
|
|
160
|
+
|
|
161
|
+
return format('%I.%I in (select via.%I from %s via where via.company_id = $1)',
|
|
162
|
+
p_alias, p_via_column, v_target, to_regclass(p_via_table)::text);
|
|
163
|
+
end;
|
|
164
|
+
$$;
|
|
165
|
+
|
|
166
|
+
comment on function company_archive_predicate_via(text, text, text, text, text) is
|
|
167
|
+
'The condition of company_archive_predicate(), from a line of company_archive_tables() the caller already holds: a loop over the tables of an archive builds it without listing them again for each one.';
|
|
168
|
+
|
|
169
|
+
create or replace function company_archive_predicate(p_schema text, p_table text, p_alias text)
|
|
170
|
+
returns text
|
|
171
|
+
language plpgsql
|
|
172
|
+
stable
|
|
173
|
+
set search_path = public, pg_temp
|
|
174
|
+
as $$
|
|
175
|
+
declare
|
|
176
|
+
v_row record;
|
|
177
|
+
begin
|
|
178
|
+
if (p_schema, p_table) = ('public', 'companies') then
|
|
179
|
+
return company_archive_predicate_via(p_schema, p_table, null, null, p_alias);
|
|
180
|
+
end if;
|
|
181
|
+
|
|
182
|
+
select * into v_row from company_archive_tables() t
|
|
183
|
+
where t.table_schema = p_schema and t.table_name = p_table;
|
|
184
|
+
if not found then
|
|
185
|
+
raise exception 'unknown_table: %.% is not a table an archive knows', p_schema, p_table;
|
|
186
|
+
end if;
|
|
187
|
+
|
|
188
|
+
return company_archive_predicate_via(p_schema, p_table, v_row.via_column, v_row.via_table, p_alias);
|
|
189
|
+
end;
|
|
190
|
+
$$;
|
|
191
|
+
|
|
192
|
+
comment on function company_archive_predicate(text, text, text) is
|
|
193
|
+
'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.';
|
|
194
|
+
|
|
195
|
+
create or replace function company_archive_rows_query(p_schema text, p_table text, p_where text)
|
|
196
|
+
returns text
|
|
197
|
+
language plpgsql
|
|
198
|
+
stable
|
|
199
|
+
set search_path = public, pg_temp
|
|
200
|
+
as $$
|
|
201
|
+
declare
|
|
202
|
+
v_class regclass := to_regclass(format('%I.%I', p_schema, p_table));
|
|
203
|
+
v_columns text;
|
|
204
|
+
v_decimals text;
|
|
205
|
+
v_order text;
|
|
206
|
+
begin
|
|
207
|
+
-- Every column but an identity.
|
|
208
|
+
select string_agg(format('t.%I', a.attname), ', ' order by a.attnum)
|
|
209
|
+
into v_columns
|
|
210
|
+
from pg_attribute a
|
|
211
|
+
where a.attrelid = v_class and a.attnum > 0 and not a.attisdropped and a.attidentity = '';
|
|
212
|
+
|
|
213
|
+
-- Decimals leave as text. `12.50` is a valid JSON number and most readers
|
|
214
|
+
-- hand it back as a float; an archive of a ledger must not depend on which.
|
|
215
|
+
select coalesce(' || jsonb_build_object(' ||
|
|
216
|
+
string_agg(format('%L, x.%I::text', a.attname, a.attname), ', ' order by a.attnum) || ')', '')
|
|
217
|
+
into v_decimals
|
|
218
|
+
from pg_attribute a
|
|
219
|
+
where a.attrelid = v_class
|
|
220
|
+
and a.attnum > 0 and not a.attisdropped
|
|
221
|
+
and a.atttypid = 'numeric'::regtype;
|
|
222
|
+
|
|
223
|
+
if exists (select 1 from pg_index i
|
|
224
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
|
|
225
|
+
where i.indrelid = v_class and i.indisprimary and a.attidentity <> '') then
|
|
226
|
+
select string_agg(format('x.%I', a.attname), ', ' order by a.attnum)
|
|
227
|
+
into v_order
|
|
228
|
+
from pg_attribute a
|
|
229
|
+
where a.attrelid = v_class and a.attnum > 0 and not a.attisdropped and a.attidentity = '';
|
|
230
|
+
else
|
|
231
|
+
select string_agg(format('x.%I', a.attname), ', ' order by array_position(i.indkey::smallint[], a.attnum))
|
|
232
|
+
into v_order
|
|
233
|
+
from pg_index i
|
|
234
|
+
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any (i.indkey)
|
|
235
|
+
where i.indrelid = v_class and i.indisprimary;
|
|
236
|
+
end if;
|
|
237
|
+
if v_order is null then
|
|
238
|
+
raise exception 'unknown_table: %.% has no primary key, so its rows have no order to be written in', p_schema, p_table;
|
|
239
|
+
end if;
|
|
240
|
+
|
|
241
|
+
return format('select to_jsonb(x)%s as r, row_number() over (order by %s) as n from (select %s from %I.%I t where %s) x',
|
|
242
|
+
v_decimals, v_order, v_columns, p_schema, p_table, p_where);
|
|
243
|
+
end;
|
|
244
|
+
$$;
|
|
245
|
+
|
|
246
|
+
comment on function company_archive_rows_query(text, text, text) is
|
|
247
|
+
'The query that writes the rows of one table of an archive, given the condition that keeps one company (`$1`): `r`, one JSON object a row — decimals as text, no identity column — and `n`, its place in primary key order, or, for a table whose primary key is an identity, in the order of every column that leaves. The one definition both export_company_table() and export_company_archive() read their rows through.';
|
|
248
|
+
|
|
249
|
+
create or replace function company_archive_row_counts(p_company_id uuid)
|
|
250
|
+
returns table (table_name text, row_count bigint)
|
|
251
|
+
language plpgsql
|
|
252
|
+
stable
|
|
253
|
+
security definer
|
|
254
|
+
set search_path = public, pg_temp
|
|
255
|
+
as $$
|
|
256
|
+
declare
|
|
257
|
+
v_table record;
|
|
258
|
+
v_count bigint;
|
|
259
|
+
begin
|
|
260
|
+
if has_capability(p_company_id, 'company.export') is not true then
|
|
261
|
+
raise exception 'not_allowed: counting the rows of this company needs company.export'
|
|
262
|
+
using errcode = '42501';
|
|
263
|
+
end if;
|
|
264
|
+
|
|
265
|
+
for v_table in
|
|
266
|
+
select t.table_schema, t.table_name, t.via_column, t.via_table
|
|
267
|
+
from company_archive_tables() t
|
|
268
|
+
where t.disposition = 'exported'
|
|
269
|
+
loop
|
|
270
|
+
execute format('select count(*) from %I.%I t where %s',
|
|
271
|
+
v_table.table_schema, v_table.table_name,
|
|
272
|
+
company_archive_predicate_via(v_table.table_schema, v_table.table_name,
|
|
273
|
+
v_table.via_column, v_table.via_table, 't'))
|
|
274
|
+
into v_count using p_company_id;
|
|
275
|
+
table_name := v_table.table_schema || '.' || v_table.table_name;
|
|
276
|
+
row_count := v_count;
|
|
277
|
+
return next;
|
|
278
|
+
end loop;
|
|
279
|
+
end;
|
|
280
|
+
$$;
|
|
281
|
+
|
|
282
|
+
comment on function company_archive_row_counts(uuid) is
|
|
283
|
+
'company_archive_row_count() for every table an archive carries, asked once: how many rows each holds for one company, whatever the caller may read of them. Definer, guarded by company.export, and it answers numbers and nothing else.';
|
|
284
|
+
|
|
285
|
+
create or replace function export_company_table(p_company_id uuid, p_table text)
|
|
286
|
+
returns setof jsonb
|
|
287
|
+
language plpgsql
|
|
288
|
+
stable
|
|
289
|
+
set search_path = public, pg_temp
|
|
290
|
+
set timezone = 'UTC'
|
|
291
|
+
as $$
|
|
292
|
+
declare
|
|
293
|
+
v_schema text := split_part(p_table, '.', 1);
|
|
294
|
+
v_name text := split_part(p_table, '.', 2);
|
|
295
|
+
v_problem record;
|
|
296
|
+
v_where text;
|
|
297
|
+
v_seen bigint;
|
|
298
|
+
v_held bigint;
|
|
299
|
+
begin
|
|
300
|
+
perform assert_may_export_company(p_company_id);
|
|
301
|
+
|
|
302
|
+
select * into v_problem from company_archive_unclassified() limit 1;
|
|
303
|
+
if found then
|
|
304
|
+
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.',
|
|
305
|
+
v_problem.table_schema, v_problem.table_name, v_problem.problem
|
|
306
|
+
using errcode = '55006';
|
|
307
|
+
end if;
|
|
308
|
+
|
|
309
|
+
if not exists (select 1 from company_archive_tables() t
|
|
310
|
+
where t.table_schema = v_schema and t.table_name = v_name
|
|
311
|
+
and t.disposition = 'exported') then
|
|
312
|
+
raise exception 'unknown_table: % is not a table an archive carries', p_table;
|
|
313
|
+
end if;
|
|
314
|
+
|
|
315
|
+
v_where := company_archive_predicate(v_schema, v_name, 't');
|
|
316
|
+
|
|
317
|
+
execute format('select count(*) from %I.%I t where %s', v_schema, v_name, v_where)
|
|
318
|
+
into v_seen using p_company_id;
|
|
319
|
+
v_held := company_archive_row_count(p_company_id, p_table);
|
|
320
|
+
if v_seen <> v_held then
|
|
321
|
+
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.',
|
|
322
|
+
p_table, v_held, v_seen
|
|
323
|
+
using errcode = '42501';
|
|
324
|
+
end if;
|
|
325
|
+
|
|
326
|
+
return query execute format('select q.r from (%s) q order by q.n',
|
|
327
|
+
company_archive_rows_query(v_schema, v_name, v_where))
|
|
328
|
+
using p_company_id;
|
|
329
|
+
end;
|
|
330
|
+
$$;
|
|
331
|
+
|
|
332
|
+
comment on function export_company_table(uuid, text) is
|
|
333
|
+
'The rows of one table for one company, one JSON object each, in primary key order — or, for a table whose primary key is an identity, which does not leave, in the order of every column that does: 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.';
|
|
334
|
+
|
|
335
|
+
create or replace function export_company_archive(p_company_id uuid, p_with_rows boolean)
|
|
336
|
+
returns jsonb
|
|
337
|
+
language plpgsql
|
|
338
|
+
stable
|
|
339
|
+
set search_path = public, pg_temp
|
|
340
|
+
set timezone = 'UTC'
|
|
341
|
+
as $$
|
|
342
|
+
declare
|
|
343
|
+
v_company companies%rowtype;
|
|
344
|
+
v_problem record;
|
|
345
|
+
v_catalog jsonb;
|
|
346
|
+
v_held jsonb;
|
|
347
|
+
v_table record;
|
|
348
|
+
v_query text;
|
|
349
|
+
v_seen bigint;
|
|
350
|
+
v_data jsonb;
|
|
351
|
+
v_checksum text;
|
|
352
|
+
v_values text;
|
|
353
|
+
v_tables jsonb := '[]'::jsonb;
|
|
354
|
+
v_names text[] := '{}';
|
|
355
|
+
v_rows jsonb[] := '{}';
|
|
356
|
+
begin
|
|
357
|
+
-- Once per archive, not once per table: the right, the company, the sweep
|
|
358
|
+
-- of the catalogue and the list of tables.
|
|
359
|
+
perform assert_may_export_company(p_company_id);
|
|
360
|
+
|
|
361
|
+
select * into v_company from companies c where c.id = p_company_id;
|
|
362
|
+
if not found then
|
|
363
|
+
raise exception 'unknown_company: % is not a company you may read', p_company_id
|
|
364
|
+
using errcode = 'P0002';
|
|
365
|
+
end if;
|
|
366
|
+
|
|
367
|
+
select * into v_problem from company_archive_unclassified() limit 1;
|
|
368
|
+
if found then
|
|
369
|
+
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.',
|
|
370
|
+
v_problem.table_schema, v_problem.table_name, v_problem.problem
|
|
371
|
+
using errcode = '55006';
|
|
372
|
+
end if;
|
|
373
|
+
|
|
374
|
+
select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v_catalog from company_archive_tables() t;
|
|
375
|
+
select coalesce(jsonb_object_agg(c.table_name, c.row_count), '{}'::jsonb)
|
|
376
|
+
into v_held
|
|
377
|
+
from company_archive_row_counts(p_company_id) c;
|
|
378
|
+
|
|
379
|
+
for v_table in
|
|
380
|
+
select t.table_schema, t.table_name, t.via_column, t.via_table,
|
|
381
|
+
t.table_schema || '.' || t.table_name as name
|
|
382
|
+
from jsonb_to_recordset(v_catalog)
|
|
383
|
+
as t(table_schema text, table_name text, disposition text, via_column text, via_table text, load_order integer)
|
|
384
|
+
where t.disposition = 'exported'
|
|
385
|
+
order by t.load_order, t.table_schema, t.table_name
|
|
386
|
+
loop
|
|
387
|
+
v_query := company_archive_rows_query(
|
|
388
|
+
v_table.table_schema, v_table.table_name,
|
|
389
|
+
company_archive_predicate_via(v_table.table_schema, v_table.table_name,
|
|
390
|
+
v_table.via_column, v_table.via_table, 't'));
|
|
391
|
+
|
|
392
|
+
-- One read of the table: the count, both checksums and the rows.
|
|
393
|
+
execute format($sql$
|
|
394
|
+
select count(*),
|
|
395
|
+
jsonb_agg(q.r order by q.n) filter (where $2),
|
|
396
|
+
encode(sha256(convert_to(coalesce(string_agg(q.r::text || E'\n', '' order by q.n), ''), 'UTF8')), 'hex'),
|
|
397
|
+
encode(sha256(convert_to(coalesce(string_agg(canonical_json(q.r)::text || E'\n', '' order by q.n), ''), 'UTF8')), 'hex')
|
|
398
|
+
from (%s) q$sql$, v_query)
|
|
399
|
+
into v_seen, v_data, v_checksum, v_values
|
|
400
|
+
using p_company_id, p_with_rows;
|
|
401
|
+
|
|
402
|
+
if v_seen is distinct from (v_held ->> v_table.name)::bigint then
|
|
403
|
+
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.',
|
|
404
|
+
v_table.name, v_held ->> v_table.name, v_seen
|
|
405
|
+
using errcode = '42501';
|
|
406
|
+
end if;
|
|
407
|
+
|
|
408
|
+
v_tables := v_tables || jsonb_build_object(
|
|
409
|
+
'name', v_table.name,
|
|
410
|
+
'file', 'data/' || v_table.name || '.jsonl',
|
|
411
|
+
'rows', v_seen,
|
|
412
|
+
'sha256', v_checksum,
|
|
413
|
+
'values_sha256', v_values);
|
|
414
|
+
if p_with_rows then
|
|
415
|
+
v_names := array_append(v_names, v_table.name);
|
|
416
|
+
v_rows := array_append(v_rows, coalesce(v_data, '[]'::jsonb));
|
|
417
|
+
end if;
|
|
418
|
+
end loop;
|
|
419
|
+
|
|
420
|
+
return jsonb_build_object(
|
|
421
|
+
'manifest', jsonb_build_object(
|
|
422
|
+
'format', 'ekwo.company-archive',
|
|
423
|
+
'format_version', 1,
|
|
424
|
+
'exported_at', to_jsonb(now()),
|
|
425
|
+
'exported_by', acting_user(),
|
|
426
|
+
'socle_version', ekwo_schema_version(),
|
|
427
|
+
'origin_instance', (select i.instance_id from instance i),
|
|
428
|
+
'company', jsonb_build_object(
|
|
429
|
+
'id', v_company.id,
|
|
430
|
+
'name', v_company.name,
|
|
431
|
+
'country', v_company.country,
|
|
432
|
+
'fiscal_country', v_company.fiscal_country,
|
|
433
|
+
'currency_code', v_company.currency_code),
|
|
434
|
+
'packs', coalesce((
|
|
435
|
+
select jsonb_agg(jsonb_build_object('country', p.country, 'version', p.version, 'chart_code', p.chart_code)
|
|
436
|
+
order by p.country)
|
|
437
|
+
from company_packs p where p.company_id = p_company_id), '[]'::jsonb),
|
|
438
|
+
'modules', coalesce((
|
|
439
|
+
select jsonb_agg(jsonb_build_object('code', m.code, 'version', m.version) order by m.code)
|
|
440
|
+
from company_modules cm join modules m on m.code = cm.module_code
|
|
441
|
+
where cm.company_id = p_company_id), '[]'::jsonb),
|
|
442
|
+
'tables', v_tables,
|
|
443
|
+
'excluded', coalesce((
|
|
444
|
+
select jsonb_agg(jsonb_build_object('name', t.table_schema || '.' || t.table_name, 'reason', t.reason)
|
|
445
|
+
order by t.table_schema, t.table_name)
|
|
446
|
+
from jsonb_to_recordset(v_catalog) as t(table_schema text, table_name text, disposition text, reason text)
|
|
447
|
+
where t.disposition = 'excluded'), '[]'::jsonb),
|
|
448
|
+
'files', jsonb_build_object(
|
|
449
|
+
'transported', false,
|
|
450
|
+
'list', coalesce((
|
|
451
|
+
select jsonb_agg(jsonb_build_object(
|
|
452
|
+
'attachment_id', a.id,
|
|
453
|
+
'storage_path', a.storage_path,
|
|
454
|
+
'file_name', a.file_name,
|
|
455
|
+
'mime_type', a.mime_type,
|
|
456
|
+
'byte_size', a.byte_size,
|
|
457
|
+
'checksum', a.checksum) order by a.id)
|
|
458
|
+
from attachments a where a.company_id = p_company_id), '[]'::jsonb))),
|
|
459
|
+
'tables', case when p_with_rows then
|
|
460
|
+
coalesce((select jsonb_object_agg(x.name, x.rows) from unnest(v_names, v_rows) as x(name, rows)), '{}'::jsonb)
|
|
461
|
+
end);
|
|
462
|
+
end;
|
|
463
|
+
$$;
|
|
464
|
+
|
|
465
|
+
comment on function export_company_archive(uuid, boolean) is
|
|
466
|
+
'The archive of one company, read once: `manifest`, and — when asked for — `tables` keyed by table name. The right, the sweep of the catalogue and the list of tables are asked once, and each table is read once for its count, both checksums and its rows. Runs as its caller and needs company.export; stable, so it reads the snapshot of the statement that calls it. What export_company(), export_company_manifest() and export_company_tables() answer.';
|
|
467
|
+
|
|
468
|
+
create or replace function export_company_manifest(p_company_id uuid)
|
|
469
|
+
returns jsonb
|
|
470
|
+
language sql
|
|
471
|
+
stable
|
|
472
|
+
set search_path = public, pg_temp
|
|
473
|
+
set timezone = 'UTC'
|
|
474
|
+
as $$
|
|
475
|
+
select export_company_archive(p_company_id, false) -> 'manifest';
|
|
476
|
+
$$;
|
|
477
|
+
|
|
478
|
+
comment on function export_company_manifest(uuid) is
|
|
479
|
+
'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.';
|
|
480
|
+
|
|
481
|
+
create or replace function export_company_tables(p_company_id uuid)
|
|
482
|
+
returns jsonb
|
|
483
|
+
language sql
|
|
484
|
+
stable
|
|
485
|
+
set search_path = public, pg_temp
|
|
486
|
+
set timezone = 'UTC'
|
|
487
|
+
as $$
|
|
488
|
+
select export_company_archive(p_company_id, true) -> 'tables';
|
|
489
|
+
$$;
|
|
490
|
+
|
|
491
|
+
comment on function export_company_tables(uuid) is
|
|
492
|
+
'Every exported table of one company as one object, keyed by table name. The `tables` half of export_company().';
|
|
493
|
+
|
|
494
|
+
create or replace function export_company(p_company_id uuid)
|
|
495
|
+
returns jsonb
|
|
496
|
+
language plpgsql
|
|
497
|
+
set search_path = public, pg_temp
|
|
498
|
+
set timezone = 'UTC'
|
|
499
|
+
as $$
|
|
500
|
+
declare
|
|
501
|
+
v_archive jsonb;
|
|
502
|
+
begin
|
|
503
|
+
perform note_company_export(p_company_id);
|
|
504
|
+
-- One statement, so one snapshot: a volatile function sees a new one at each
|
|
505
|
+
-- statement, and an archive whose manifest was computed a moment before its
|
|
506
|
+
-- rows, or whose lines were read a moment after their entries, is one that
|
|
507
|
+
-- fails on arrival — after the company has left. The reader is stable and
|
|
508
|
+
-- runs inside the snapshot of this select.
|
|
509
|
+
select export_company_archive(p_company_id, true) into v_archive;
|
|
510
|
+
return v_archive;
|
|
511
|
+
end;
|
|
512
|
+
$$;
|
|
513
|
+
|
|
514
|
+
comment on function export_company(uuid) is
|
|
515
|
+
'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.';
|
|
516
|
+
|
|
517
|
+
-- ---------------------------------------------------------------------------
|
|
518
|
+
-- 3. The trail asks who reads it once per statement
|
|
519
|
+
-- ---------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
create or replace function caller_companies()
|
|
522
|
+
returns uuid[]
|
|
523
|
+
language sql
|
|
524
|
+
stable
|
|
525
|
+
security definer
|
|
526
|
+
set search_path = public, pg_temp
|
|
527
|
+
as $$
|
|
528
|
+
select coalesce(array_agg(x.company_id), '{}'::uuid[])
|
|
529
|
+
from (
|
|
530
|
+
select m.company_id
|
|
531
|
+
from company_members m
|
|
532
|
+
where m.user_id = auth.uid()
|
|
533
|
+
union
|
|
534
|
+
select k.company_id
|
|
535
|
+
from api_keys k
|
|
536
|
+
where k.key_hash = nullif(current_setting('ekwo.api_key', true), '')
|
|
537
|
+
) x
|
|
538
|
+
where is_company_member(x.company_id);
|
|
539
|
+
$$;
|
|
540
|
+
|
|
541
|
+
comment on function caller_companies() is
|
|
542
|
+
'The companies the current caller is on — the question is_company_member() answers, asked once for all of them, over the only companies where its answer can be yes. What a row level security policy compares company_id against, inside a sub-select, so that it is worked out once per statement instead of once per row.';
|
|
543
|
+
|
|
544
|
+
alter policy audit_log_select on audit_log
|
|
545
|
+
using (
|
|
546
|
+
case when company_id is null then (select is_instance_admin())
|
|
547
|
+
else company_id = any ((select caller_companies())::uuid[]) or (select is_instance_admin())
|
|
548
|
+
end
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
-- ---------------------------------------------------------------------------
|
|
552
|
+
-- Grants
|
|
553
|
+
--
|
|
554
|
+
-- The functions of an export are reachable by a signed-in user and guarded
|
|
555
|
+
-- inside, which is the doctrine of 20260914151207: the two builders of SQL
|
|
556
|
+
-- answer text a caller could write themselves, the count and the archive
|
|
557
|
+
-- check company.export before anything else.
|
|
558
|
+
-- ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
revoke execute on all functions in schema public from public;
|
|
561
|
+
|
|
562
|
+
revoke execute on function authenticated_statement_timeout() from public, anon;
|
|
563
|
+
revoke execute on function company_archive_predicate_via(text, text, text, text, text) from public, anon;
|
|
564
|
+
revoke execute on function company_archive_rows_query(text, text, text) from public, anon;
|
|
565
|
+
revoke execute on function company_archive_row_counts(uuid) from public, anon;
|
|
566
|
+
revoke execute on function export_company_archive(uuid, boolean) from public, anon;
|
|
567
|
+
revoke execute on function caller_companies() from public, anon;
|
|
568
|
+
|
|
569
|
+
grant execute on function authenticated_statement_timeout() to authenticated, service_role;
|
|
570
|
+
grant execute on function company_archive_predicate_via(text, text, text, text, text) to authenticated, service_role;
|
|
571
|
+
grant execute on function company_archive_rows_query(text, text, text) to authenticated, service_role;
|
|
572
|
+
grant execute on function company_archive_row_counts(uuid) to authenticated, service_role;
|
|
573
|
+
grant execute on function export_company_archive(uuid, boolean) to authenticated, service_role;
|
|
574
|
+
grant execute on function caller_companies() to authenticated, service_role;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Ekwo OS — the schema this release defines is 0.11.1.
|
|
2
|
+
--
|
|
3
|
+
-- `ekwo_schema_version()` is the one place the number lives, and a migration
|
|
4
|
+
-- is the only thing that moves it. A database then answers for the files it
|
|
5
|
+
-- has actually run rather than for what somebody believed it had run: the
|
|
6
|
+
-- column default and `init_instance()` both call this function, so a fresh
|
|
7
|
+
-- install records the new number without a second edit, and `ekwo migrate`
|
|
8
|
+
-- reads it back onto `instance.schema_version` for a database that was
|
|
9
|
+
-- installed at an older one.
|
|
10
|
+
--
|
|
11
|
+
-- Between 0.11.0 and here a request made with a machine key runs on the
|
|
12
|
+
-- statement_timeout of `authenticated` rather than on the one of `anon`
|
|
13
|
+
-- (`authenticated_statement_timeout()`), and an export reads each table of a
|
|
14
|
+
-- company once (`export_company_archive()`, `company_archive_row_counts()`,
|
|
15
|
+
-- `company_archive_rows_query()`, `company_archive_predicate_via()`), writing
|
|
16
|
+
-- the same bytes as before. The trail asks who reads it once per statement
|
|
17
|
+
-- (`caller_companies()`).
|
|
18
|
+
--
|
|
19
|
+
-- The floor of `ekwo-os`, `@ekwo-ai/core` and `@ekwo-ai/mcp` stays at 0.11.0:
|
|
20
|
+
-- no package of this release reads anything a 0.11.0 database does not have.
|
|
21
|
+
|
|
22
|
+
create or replace function ekwo_schema_version()
|
|
23
|
+
returns text
|
|
24
|
+
language sql
|
|
25
|
+
immutable
|
|
26
|
+
as $$
|
|
27
|
+
select '0.11.1'::text;
|
|
28
|
+
$$;
|
|
29
|
+
|
|
30
|
+
comment on function ekwo_schema_version() is
|
|
31
|
+
'Schema version of the installed release. Bumped by a migration, never by hand.';
|
|
32
|
+
|
|
33
|
+
-- `create or replace` keeps the privileges the function already had, and the
|
|
34
|
+
-- revoke is what every migration of this directory ends with: a function that
|
|
35
|
+
-- comes out with the built-in default is published by Supabase as an
|
|
36
|
+
-- anonymous RPC endpoint.
|
|
37
|
+
revoke execute on all functions in schema public from public;
|
|
38
|
+
grant execute on function ekwo_schema_version() to authenticated, service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ekwo-os",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Install and operate Ekwo OS on your own Supabase project: npx ekwo-os init.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accounting",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@ekwo-ai/core": "^0.11.
|
|
53
|
+
"@ekwo-ai/core": "^0.11.1",
|
|
54
54
|
"postgres": "^3.4.5"
|
|
55
55
|
}
|
|
56
56
|
}
|