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.0",
3
- "migration": "20261007180000",
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.0",
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.0",
53
+ "@ekwo-ai/core": "^0.11.1",
54
54
  "postgres": "^3.4.5"
55
55
  }
56
56
  }