letopis 0.20.3 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/load.js
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Загрузчик (план, §2.6, этап 4, п. 11): массовая запись для администратора. Данные проверяет код
|
|
3
|
+
* библиотеки (валидатор на TypeScript из того же описания, что у базы), а в базу строки уходят
|
|
4
|
+
* командой COPY от роли владельца — без триггеров и RLS и, в режиме по умолчанию, без запросов на
|
|
5
|
+
* пачку. Единственное, чего код проверить не может, — объекты вне загрузки: их сверяет один запрос
|
|
6
|
+
* в конце с блокировкой for key share до фиксации (отложенный внешний ключ одним запросом).
|
|
7
|
+
* Режим доверия убран решением 2 точки А: этот запрос стоит 0,1–1,3 % времени загрузки.
|
|
8
|
+
*
|
|
9
|
+
* await db.load({ Org: orgs, Staff: staff }) // объект «класс → строки»
|
|
10
|
+
* await db.load(rows) // массив или поток строк с полем $class
|
|
11
|
+
*
|
|
12
|
+
* Строка — объект: поля данных и роли концов на верхнем уровне; $class — класс строки, $ext —
|
|
13
|
+
* внешний ключ класса без ключа (повторяемый импорт), $tags — теги, $owner — владелец (аккаунт; не
|
|
14
|
+
* с { as }: там владелец по ownerDefault). Значение конца — id, { $key: [...] } или { $ext: '…' }, у
|
|
15
|
+
* полиморфного конца — с $class цели. Ключи на $ внутри данных — обычные данные.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { LetopisError, fromDbError, removed } from './errors.js';
|
|
19
|
+
import { isOp } from './ops.js';
|
|
20
|
+
import { Registry } from './registry.js';
|
|
21
|
+
import { accepts } from './sql.js';
|
|
22
|
+
import { v5Id } from './uuid.js';
|
|
23
|
+
import { applyDefaults } from './validate.js';
|
|
24
|
+
import { isInc } from './write.js';
|
|
25
|
+
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
26
|
+
/** Классы, строки которых меняют права: загрузка их строк повышает счётчик изменений прав. */
|
|
27
|
+
const ACL_CLASSES = ['rule', 'Resource', 'member', 'Account', 'Class'];
|
|
28
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
const esc = (v) => {
|
|
30
|
+
if (v === null || v === undefined)
|
|
31
|
+
return '\\N';
|
|
32
|
+
return String(v).replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
33
|
+
};
|
|
34
|
+
const hex = (b) => (b ? '\\x' + b.toString('hex') : null);
|
|
35
|
+
const ENTITY_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator';
|
|
36
|
+
const LOG_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, tx';
|
|
37
|
+
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
38
|
+
/** Теги → литерал text[] для COPY (экранирование COPY — потом, в esc). */
|
|
39
|
+
const pgArray = (tags) => `{${tags.map((t) => `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(',')}}`;
|
|
40
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
41
|
+
/** Предел списка нарушений в ошибке загрузки (detail.rows); счёт нарушений — без предела. */
|
|
42
|
+
const ISSUES_LIMIT = 100;
|
|
43
|
+
/**
|
|
44
|
+
* Нарушения копятся; загрузка отменяется с кодом первого. detail: rows — первые ISSUES_LIMIT нарушений,
|
|
45
|
+
* total — все найденные, truncated — список обрезан, limit — предел, complete — проверки, нашедшие
|
|
46
|
+
* нарушения, прошли все строки. Иначе (unchecked — почему) нарушений может быть больше total, и текст
|
|
47
|
+
* говорит «не меньше».
|
|
48
|
+
*/
|
|
49
|
+
class Issues {
|
|
50
|
+
list = [];
|
|
51
|
+
total = 0;
|
|
52
|
+
/** Почему проверены не все строки: следующие порции, пачки без условий ACL, предел проверки базы. */
|
|
53
|
+
unchecked = new Set();
|
|
54
|
+
add(i) {
|
|
55
|
+
this.total++;
|
|
56
|
+
if (this.list.length < ISSUES_LIMIT)
|
|
57
|
+
this.list.push(i);
|
|
58
|
+
}
|
|
59
|
+
get any() {
|
|
60
|
+
return this.total > 0;
|
|
61
|
+
}
|
|
62
|
+
fail() {
|
|
63
|
+
const first = this.list[0];
|
|
64
|
+
const rest = this.total - 1;
|
|
65
|
+
const why = [...this.unchecked].join('; ');
|
|
66
|
+
const more = why ? ` (${rest ? `и ещё не меньше ${rest}` : 'возможно, не единственная'}: ${why})` : rest ? ` (и ещё ${rest})` : '';
|
|
67
|
+
throw new LetopisError(first.code, `загрузка отменена: строка ${first.line} — ${first.message}${more}`, { rows: this.list, total: this.total, truncated: this.total > this.list.length, limit: ISSUES_LIMIT, complete: !why });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Вход → доступ к строке по номеру (массивы и объект «класс → строки» — без копии). */
|
|
71
|
+
async function toList(input, cls) {
|
|
72
|
+
const tag = (rows, c) => Array.from(rows, (r) => ({ ...r, $class: c }));
|
|
73
|
+
if (Array.isArray(input))
|
|
74
|
+
return cls ? input.map((r) => (r.$class ? r : { ...r, $class: cls })) : input;
|
|
75
|
+
if (input && typeof input[Symbol.asyncIterator] === 'function') {
|
|
76
|
+
const out = [];
|
|
77
|
+
for await (const r of input)
|
|
78
|
+
out.push(cls && !r.$class ? { ...r, $class: cls } : r);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
if (input && typeof input[Symbol.iterator] === 'function') {
|
|
82
|
+
return Array.from(input, (r) => (cls && !r.$class ? { ...r, $class: cls } : r));
|
|
83
|
+
}
|
|
84
|
+
if (isObj(input))
|
|
85
|
+
return Object.entries(input).flatMap(([c, rows]) => tag(rows, c));
|
|
86
|
+
throw new LetopisError('invalid_data', 'load(): вход — объект «класс → строки», массив или поток строк с $class');
|
|
87
|
+
}
|
|
88
|
+
/** Ссылка на цель: id, { $key: […] } или { $ext } (у полиморфного конца — с $class). */
|
|
89
|
+
function refOf(reg, tenant, end, v) {
|
|
90
|
+
if (typeof v === 'string')
|
|
91
|
+
return UUID.test(v) ? { id: v.toLowerCase() } : { error: 'значение конца не uuid' };
|
|
92
|
+
if (!isObj(v) || (!('$key' in v) && !('$ext' in v)))
|
|
93
|
+
return { error: 'значение конца — id, { $key } или { $ext }' };
|
|
94
|
+
const classes = asList(end.class);
|
|
95
|
+
const name = typeof v.$class === 'string' ? v.$class : classes.length === 1 ? classes[0] : null;
|
|
96
|
+
if (!name)
|
|
97
|
+
return { error: 'полиморфный конец: укажите $class цели' };
|
|
98
|
+
const c = reg.find(name);
|
|
99
|
+
if (!c || c.abstract)
|
|
100
|
+
return { error: `класс цели ${name} не найден или абстрактный` };
|
|
101
|
+
if ('$key' in v) {
|
|
102
|
+
const vals = v.$key;
|
|
103
|
+
if (!Array.isArray(vals) || vals.length !== c.key.length || !vals.every((x) => typeof x === 'string')) {
|
|
104
|
+
return { error: `$key цели ${c.name} — список строк ключа [${c.key.join(', ')}]` };
|
|
105
|
+
}
|
|
106
|
+
return { id: v5Id(tenant, c.name, vals.map((x, i) => (c.ends[c.key[i]] ? x.toLowerCase() : x))) };
|
|
107
|
+
}
|
|
108
|
+
if (c.key.length)
|
|
109
|
+
return { error: `$ext — для класса без ключа, а у ${c.name} ключ [${c.key.join(', ')}]` };
|
|
110
|
+
if (typeof v.$ext !== 'string')
|
|
111
|
+
return { error: '$ext — строка' };
|
|
112
|
+
return { id: v5Id(tenant, c.name, [v.$ext]) };
|
|
113
|
+
}
|
|
114
|
+
/** Строка → данные и концы: поля верхнего уровня, роли, пометки на $; проверки по описанию класса. */
|
|
115
|
+
function shape(reg, tenant, cls, raw, check) {
|
|
116
|
+
const data = {};
|
|
117
|
+
const links = {};
|
|
118
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
119
|
+
if (k.startsWith('$'))
|
|
120
|
+
continue;
|
|
121
|
+
if (cls.ends[k]) {
|
|
122
|
+
if (v === null || v === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const end = cls.ends[k];
|
|
125
|
+
if (end.many) {
|
|
126
|
+
if (!Array.isArray(v))
|
|
127
|
+
return { data, links, error: { code: 'invalid_data', message: `конец ${k} — список целей` } };
|
|
128
|
+
const ids = [];
|
|
129
|
+
for (const x of v) {
|
|
130
|
+
const r = refOf(reg, tenant, end, x);
|
|
131
|
+
if ('error' in r)
|
|
132
|
+
return { data, links, error: { code: 'invalid_data', message: `конец ${k}: ${r.error}` } };
|
|
133
|
+
if (ids.includes(r.id))
|
|
134
|
+
return { data, links, error: { code: 'invalid_data', message: `конец ${k}: цель повторяется` } };
|
|
135
|
+
ids.push(r.id);
|
|
136
|
+
}
|
|
137
|
+
links[k] = ids;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const r = refOf(reg, tenant, end, v);
|
|
141
|
+
if ('error' in r)
|
|
142
|
+
return { data, links, error: { code: 'invalid_data', message: `конец ${k}: ${r.error}` } };
|
|
143
|
+
links[k] = r.id;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
data[k] = v;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!check)
|
|
151
|
+
return { data, links };
|
|
152
|
+
const scan = (v, path) => {
|
|
153
|
+
if (isInc(v))
|
|
154
|
+
return `inc() — только в update() (${path})`;
|
|
155
|
+
if (isOp(v))
|
|
156
|
+
return `оператор фильтра в данных (${path})`;
|
|
157
|
+
if (Array.isArray(v)) {
|
|
158
|
+
for (let i = 0; i < v.length; i++) {
|
|
159
|
+
const m = scan(v[i], `${path}.${i}`);
|
|
160
|
+
if (m)
|
|
161
|
+
return m;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (isObj(v)) {
|
|
165
|
+
for (const [k, x] of Object.entries(v)) {
|
|
166
|
+
const m = scan(x, path ? `${path}.${k}` : k);
|
|
167
|
+
if (m)
|
|
168
|
+
return m;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
};
|
|
173
|
+
const bad = scan(data, '');
|
|
174
|
+
if (bad)
|
|
175
|
+
return { data, links, error: { code: 'invalid_data', message: bad } };
|
|
176
|
+
const full = applyDefaults(cls.schema, data);
|
|
177
|
+
const issues = cls.validator()(full);
|
|
178
|
+
if (issues.length)
|
|
179
|
+
return { data: full, links, error: { code: 'invalid_data', message: `данные не проходят описание класса ${cls.name}`, issues } };
|
|
180
|
+
for (const [role, end] of Object.entries(cls.ends)) {
|
|
181
|
+
const n = end.many ? (links[role]?.length ?? 0) : links[role] ? 1 : 0;
|
|
182
|
+
const min = Math.max(end.min ?? 0, end.required ? 1 : 0);
|
|
183
|
+
if (end.many ? n < min : end.required && !n)
|
|
184
|
+
return { data: full, links, error: { code: 'invalid_data', message: end.many ? `у конца ${role} слишком мало целей` : `обязательный конец ${role} не задан` } };
|
|
185
|
+
if (end.many && end.max !== undefined && n > end.max)
|
|
186
|
+
return { data: full, links, error: { code: 'invalid_data', message: `у конца ${role} слишком много целей` } };
|
|
187
|
+
}
|
|
188
|
+
return { data: full, links };
|
|
189
|
+
}
|
|
190
|
+
/** id строки: класс с ключом — v5 от ключа; без ключа — от $ext или от случайной части (§2.3). */
|
|
191
|
+
function idOfRow(reg, tenant, cls, raw) {
|
|
192
|
+
if (!cls.key.length) {
|
|
193
|
+
if (raw.$ext !== undefined)
|
|
194
|
+
return typeof raw.$ext === 'string' ? v5Id(tenant, cls.name, [raw.$ext]) : { error: '$ext — строка' };
|
|
195
|
+
return v5Id(tenant, cls.name, [randomUUID()]);
|
|
196
|
+
}
|
|
197
|
+
const vals = [];
|
|
198
|
+
for (const k of cls.key) {
|
|
199
|
+
if (cls.ends[k]) {
|
|
200
|
+
const r = refOf(reg, tenant, cls.ends[k], raw[k]);
|
|
201
|
+
if ('error' in r)
|
|
202
|
+
return { error: `часть ключа ${k}: ${r.error}` };
|
|
203
|
+
vals.push(r.id);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const v = raw[k] ?? cls.schema.properties?.[k]?.default;
|
|
207
|
+
if (typeof v !== 'string')
|
|
208
|
+
return { error: `часть ключа ${k} — строка` };
|
|
209
|
+
vals.push(v);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return v5Id(tenant, cls.name, vals);
|
|
213
|
+
}
|
|
214
|
+
/** Порядок классов для порций: цели раньше ссылающихся; классы одного цикла — одной группой. */
|
|
215
|
+
function classGroups(reg, names) {
|
|
216
|
+
const set = new Set(names);
|
|
217
|
+
const deps = new Map();
|
|
218
|
+
for (const n of names) {
|
|
219
|
+
const c = reg.resolve(n);
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const e of Object.values(c.ends))
|
|
222
|
+
for (const t of names)
|
|
223
|
+
if (accepts(reg, asList(e.class), t))
|
|
224
|
+
out.push(t);
|
|
225
|
+
deps.set(n, out.filter((x) => set.has(x)));
|
|
226
|
+
}
|
|
227
|
+
// Тарьян: компоненты сильной связности выходят в порядке «цели раньше»
|
|
228
|
+
let index = 0;
|
|
229
|
+
const idx = new Map();
|
|
230
|
+
const low = new Map();
|
|
231
|
+
const stack = [];
|
|
232
|
+
const on = new Set();
|
|
233
|
+
const groups = [];
|
|
234
|
+
const visit = (v) => {
|
|
235
|
+
idx.set(v, index);
|
|
236
|
+
low.set(v, index++);
|
|
237
|
+
stack.push(v);
|
|
238
|
+
on.add(v);
|
|
239
|
+
for (const w of deps.get(v) ?? []) {
|
|
240
|
+
if (!idx.has(w)) {
|
|
241
|
+
visit(w);
|
|
242
|
+
low.set(v, Math.min(low.get(v), low.get(w)));
|
|
243
|
+
}
|
|
244
|
+
else if (on.has(w))
|
|
245
|
+
low.set(v, Math.min(low.get(v), idx.get(w)));
|
|
246
|
+
}
|
|
247
|
+
if (low.get(v) === idx.get(v)) {
|
|
248
|
+
const g = [];
|
|
249
|
+
let w;
|
|
250
|
+
do {
|
|
251
|
+
w = stack.pop();
|
|
252
|
+
on.delete(w);
|
|
253
|
+
g.push(w);
|
|
254
|
+
} while (w !== v);
|
|
255
|
+
groups.push(g);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
for (const n of names)
|
|
259
|
+
if (!idx.has(n))
|
|
260
|
+
visit(n);
|
|
261
|
+
return groups;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* COPY … from stdin. postgres.js 3.4 теряет ошибку сервера, пришедшую после конца потока: запрос
|
|
265
|
+
* уже разрешён потоком, а колбэк завершения не вызывается — промис повис бы. Поэтому reject
|
|
266
|
+
* запроса перехватывается до начала потока и отклоняет ожидание.
|
|
267
|
+
*/
|
|
268
|
+
async function copyLines(conn, text, lines) {
|
|
269
|
+
if (!lines.length)
|
|
270
|
+
return;
|
|
271
|
+
const q = conn.unsafe(text);
|
|
272
|
+
let failed = null;
|
|
273
|
+
let early = null;
|
|
274
|
+
const reject = q.reject;
|
|
275
|
+
q.reject = (err) => {
|
|
276
|
+
if (failed)
|
|
277
|
+
failed(err);
|
|
278
|
+
else
|
|
279
|
+
early = err;
|
|
280
|
+
reject(err);
|
|
281
|
+
};
|
|
282
|
+
const w = await q.writable();
|
|
283
|
+
await new Promise((res, rej) => {
|
|
284
|
+
if (early)
|
|
285
|
+
return rej(early);
|
|
286
|
+
failed = rej;
|
|
287
|
+
w.on('error', rej);
|
|
288
|
+
let i = 0;
|
|
289
|
+
const pump = () => {
|
|
290
|
+
while (i < lines.length) {
|
|
291
|
+
if (!w.write(lines[i++])) {
|
|
292
|
+
w.once('drain', pump);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
w.end(() => res());
|
|
297
|
+
};
|
|
298
|
+
pump();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
|
|
303
|
+
* разделяемыми блокировками семейств, проверки в коде, COPY пачками, запрос по целям вне загрузки,
|
|
304
|
+
* сигнал, ANALYZE для больших загрузок. Ошибка любой строки отменяет загрузку (порцию) — отчёт
|
|
305
|
+
* называет строки и нарушения.
|
|
306
|
+
*/
|
|
307
|
+
export async function load(env, input, opts = {}) {
|
|
308
|
+
if ('trust' in opts)
|
|
309
|
+
throw removed('load({ trust })', 'запрос по целям вне загрузки стоит 0,1–1,3 % её времени — режим доверия убран (решение 2 точки А)');
|
|
310
|
+
if (!env.admin)
|
|
311
|
+
throw new LetopisError('admin_required', 'загрузчику нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
312
|
+
const asAcc = opts.as;
|
|
313
|
+
if (asAcc !== undefined && (typeof asAcc !== 'string' || !UUID.test(asAcc)))
|
|
314
|
+
throw new LetopisError('invalid_data', 'load({ as }): id аккаунта');
|
|
315
|
+
if (asAcc !== undefined && opts.withHistory)
|
|
316
|
+
throw new LetopisError('invalid_data', 'load({ as }) и режим истории несовместимы: историю импортирует администратор');
|
|
317
|
+
if (opts.withHistory && opts.existing === 'update')
|
|
318
|
+
throw new LetopisError('invalid_data', "режим истории: existing — 'error' или 'skip' (новую версию существующего объекта пишет загрузка без истории)");
|
|
319
|
+
const tenant = opts.tenant ?? asAcc ?? env.tenant;
|
|
320
|
+
if (!tenant)
|
|
321
|
+
throw new LetopisError('invalid_data', 'load(): нет арендатора — сессия без арендатора и не задан { tenant }');
|
|
322
|
+
const dupMode = opts.duplicates ?? 'error';
|
|
323
|
+
const existing = opts.existing ?? 'error';
|
|
324
|
+
const batch = opts.batch ?? 10000;
|
|
325
|
+
const agent = opts.agent ?? 'letopis load';
|
|
326
|
+
const author = asAcc ?? SYSTEM_ID;
|
|
327
|
+
const S = `"${env.schema.replace(/"/g, '""')}"`;
|
|
328
|
+
const t0 = performance.now();
|
|
329
|
+
const rows = await toList(input, opts.class);
|
|
330
|
+
const report = { rows: rows.length, loaded: 0, updated: 0, skipped: 0, duplicates: 0, byClass: {}, external: 0, portions: 0, ms: 0 };
|
|
331
|
+
if (!rows.length)
|
|
332
|
+
return report;
|
|
333
|
+
const readRegistry = async (q) => {
|
|
334
|
+
const reg = new Registry();
|
|
335
|
+
reg.load(await q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in ($1::uuid, $2::uuid)`, [tenant, SYSTEM_ID]));
|
|
336
|
+
return reg;
|
|
337
|
+
};
|
|
338
|
+
// проход 1: классы, id строк, повторы
|
|
339
|
+
const issues = new Issues();
|
|
340
|
+
/** Планы прав пользователя (load({ as })) текущей порции. */
|
|
341
|
+
let acl = null;
|
|
342
|
+
/** Условия ACL уже проверяли пачку этой порции: пачку, пропущенную из-за нарушений, они не проверят. */
|
|
343
|
+
let aclRan = false;
|
|
344
|
+
/** Цели ссылок порции (load({ as })): id → первая строка и роль — для проверки видимости. */
|
|
345
|
+
const targets = new Map();
|
|
346
|
+
/** Владельцы ($owner) порции, кроме арендатора: id → первая строка; сверяются запросом в конце. */
|
|
347
|
+
const owners = new Map();
|
|
348
|
+
const reg0 = await readRegistry(env.sql);
|
|
349
|
+
const p1 = new Array(rows.length);
|
|
350
|
+
const lineOf = new Map();
|
|
351
|
+
const chosen = new Set();
|
|
352
|
+
const versions = new Map(); // режим истории: id → строки версий
|
|
353
|
+
for (let i = 0; i < rows.length; i++) {
|
|
354
|
+
const raw = rows[i];
|
|
355
|
+
const line = i + 1;
|
|
356
|
+
const name = raw?.$class;
|
|
357
|
+
const cls = typeof name === 'string' ? reg0.find(name) : undefined;
|
|
358
|
+
if (!cls) {
|
|
359
|
+
issues.add({ line, code: 'invalid_class', message: typeof name === 'string' ? `класс ${name} не найден` : 'нет $class' });
|
|
360
|
+
p1[i] = null;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (cls.name === 'Class') {
|
|
364
|
+
issues.add({ line, code: 'class_not_loadable', message: 'строки Class задают letopis sync и create — там база компилирует проверку', class: cls.name });
|
|
365
|
+
p1[i] = null;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (cls.abstract) {
|
|
369
|
+
issues.add({ line, code: 'invalid_class', message: `класс ${cls.name} абстрактный`, class: cls.name });
|
|
370
|
+
p1[i] = null;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const id = idOfRow(reg0, tenant, cls, raw);
|
|
374
|
+
if (typeof id !== 'string') {
|
|
375
|
+
issues.add({ line, code: 'invalid_data', message: id.error, class: cls.name });
|
|
376
|
+
p1[i] = null;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
p1[i] = { cls, id };
|
|
380
|
+
if (opts.withHistory) {
|
|
381
|
+
const list = versions.get(id) ?? [];
|
|
382
|
+
list.push(i);
|
|
383
|
+
versions.set(id, list);
|
|
384
|
+
chosen.add(i);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const prev = lineOf.get(id);
|
|
388
|
+
if (prev === undefined) {
|
|
389
|
+
lineOf.set(id, i);
|
|
390
|
+
chosen.add(i);
|
|
391
|
+
}
|
|
392
|
+
else if (dupMode === 'error') {
|
|
393
|
+
issues.add({ line, code: 'duplicate', message: `id ${id} уже был в строке ${prev + 1}`, id, class: cls.name });
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
report.duplicates++;
|
|
397
|
+
if (dupMode === 'last') {
|
|
398
|
+
chosen.delete(prev);
|
|
399
|
+
chosen.add(i);
|
|
400
|
+
lineOf.set(id, i);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (issues.any)
|
|
405
|
+
issues.fail();
|
|
406
|
+
// порции: классы в порядке зависимостей, цикл — целиком; без commitEvery — одна порция
|
|
407
|
+
const names = [...new Set(p1.filter((x) => !!x).map((x) => x.cls.name))];
|
|
408
|
+
let portions = [[...chosen].sort((a, b) => a - b)];
|
|
409
|
+
if (opts.commitEvery) {
|
|
410
|
+
const byClass = new Map();
|
|
411
|
+
for (const i of [...chosen].sort((a, b) => a - b)) {
|
|
412
|
+
const n = p1[i].cls.name;
|
|
413
|
+
if (!byClass.has(n))
|
|
414
|
+
byClass.set(n, []);
|
|
415
|
+
byClass.get(n).push(i);
|
|
416
|
+
}
|
|
417
|
+
portions = [];
|
|
418
|
+
let cur = [];
|
|
419
|
+
for (const g of classGroups(reg0, names)) {
|
|
420
|
+
if (g.length === 1 && !opts.withHistory) {
|
|
421
|
+
// класс без цикла можно резать: ссылки идут на уже записанные классы
|
|
422
|
+
const self = reg0.resolve(g[0]);
|
|
423
|
+
const selfRef = Object.values(self.ends).some((e) => accepts(reg0, asList(e.class), g[0]));
|
|
424
|
+
if (!selfRef) {
|
|
425
|
+
for (const i of byClass.get(g[0]) ?? []) {
|
|
426
|
+
cur.push(i);
|
|
427
|
+
if (cur.length >= opts.commitEvery) {
|
|
428
|
+
portions.push(cur);
|
|
429
|
+
cur = [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (const n of g)
|
|
436
|
+
for (const i of byClass.get(n) ?? [])
|
|
437
|
+
cur.push(i);
|
|
438
|
+
if (cur.length >= opts.commitEvery) {
|
|
439
|
+
portions.push(cur);
|
|
440
|
+
cur = [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (cur.length)
|
|
444
|
+
portions.push(cur);
|
|
445
|
+
}
|
|
446
|
+
for (const portion of portions) {
|
|
447
|
+
await loadPortion(portion);
|
|
448
|
+
report.portions++;
|
|
449
|
+
}
|
|
450
|
+
report.ms = performance.now() - t0;
|
|
451
|
+
return report;
|
|
452
|
+
/** Одна порция — одна транзакция. */
|
|
453
|
+
async function loadPortion(portion) {
|
|
454
|
+
acl = null;
|
|
455
|
+
aclRan = false;
|
|
456
|
+
targets.clear();
|
|
457
|
+
owners.clear();
|
|
458
|
+
issues.unchecked.clear();
|
|
459
|
+
laterUnchecked(portion, issues);
|
|
460
|
+
const conn = await env.sql.reserve();
|
|
461
|
+
const written = [];
|
|
462
|
+
let failed = null;
|
|
463
|
+
try {
|
|
464
|
+
await conn.unsafe('begin isolation level read committed');
|
|
465
|
+
await conn.unsafe(`set local role ${`"${env.ownerRole.replace(/"/g, '""')}"`}`);
|
|
466
|
+
await conn.unsafe(`select set_config('TimeZone', 'UTC', true)`);
|
|
467
|
+
// описания классов под разделяемыми блокировками семейств, затем — свежие
|
|
468
|
+
const roots = [...new Set(portion.map((i) => p1[i].cls.ancestors[p1[i].cls.ancestors.length - 1]))].sort();
|
|
469
|
+
// ключ блокировки — арендатор корневого класса и имя корня (корень из System — ключ System)
|
|
470
|
+
for (const r of roots)
|
|
471
|
+
await conn.unsafe(`select ${S}.family_lock(${S}.class_row($1, $2::uuid), false)`, [r, tenant]);
|
|
472
|
+
const reg = await readRegistry(conn);
|
|
473
|
+
const [{ ts, xid }] = await conn.unsafe(`select clock_timestamp()::text as ts, pg_current_xact_id()::text as xid`);
|
|
474
|
+
// от имени пользователя: арендатор доступен ему, планы прав READ и WRITE; класс без права — отказ
|
|
475
|
+
if (asAcc) {
|
|
476
|
+
const [{ s: subj }] = await conn.unsafe(`select ${S}.acl_subject($1::uuid, $2::uuid) as s`, [asAcc, tenant]);
|
|
477
|
+
acl = { read: subj.read, write: subj.write };
|
|
478
|
+
const denied = new Set();
|
|
479
|
+
for (const i of portion) {
|
|
480
|
+
const n = p1[i].cls.name;
|
|
481
|
+
if (!(n in acl.write) && !denied.has(n)) {
|
|
482
|
+
denied.add(n);
|
|
483
|
+
issues.add({ line: i + 1, code: 'acl_denied', message: `нет права WRITE на класс ${n}`, class: n });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (issues.any)
|
|
487
|
+
issues.fail();
|
|
488
|
+
}
|
|
489
|
+
const inLoad = new Map();
|
|
490
|
+
for (const i of portion) {
|
|
491
|
+
const { id, cls } = p1[i];
|
|
492
|
+
const alive = !opts.withHistory || rows[i].$op !== 'delete' || !isLastVersion(i);
|
|
493
|
+
const prev = inLoad.get(id);
|
|
494
|
+
inLoad.set(id, { cls: cls.name, alive: prev && opts.withHistory ? (isLastVersion(i) ? alive : prev.alive) : alive });
|
|
495
|
+
}
|
|
496
|
+
const external = new Map();
|
|
497
|
+
const classesTouched = new Set();
|
|
498
|
+
// существующие объекты — только в режимах skip и update, одним запросом на пачку и до проверок:
|
|
499
|
+
// пропущенный объект для ссылок на него — вне загрузки (его сверит запрос в конце)
|
|
500
|
+
const live = new Set();
|
|
501
|
+
const past = new Map();
|
|
502
|
+
if (existing !== 'error' && !opts.withHistory) {
|
|
503
|
+
for (let b = 0; b < portion.length; b += batch) {
|
|
504
|
+
const ids = portion.slice(b, b + batch).map((i) => p1[i].id);
|
|
505
|
+
const lock = existing === 'update' ? ' order by e.id for no key update' : '';
|
|
506
|
+
for (const r of await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
507
|
+
live.add(r.id);
|
|
508
|
+
for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.hash, l.at::text as at from ${S}.log l
|
|
509
|
+
where l.id = any ($1::uuid[]) order by l.id, l.rev desc`, [ids]))
|
|
510
|
+
past.set(r.id, r);
|
|
511
|
+
}
|
|
512
|
+
if (existing === 'skip')
|
|
513
|
+
for (const i of portion)
|
|
514
|
+
if (live.has(p1[i].id) || past.has(p1[i].id))
|
|
515
|
+
inLoad.delete(p1[i].id);
|
|
516
|
+
}
|
|
517
|
+
// режим истории, skip: объект, у которого уже есть строка или журнал, пропускается со всей историей
|
|
518
|
+
let todo = portion;
|
|
519
|
+
if (existing === 'skip' && opts.withHistory) {
|
|
520
|
+
const taken = new Set();
|
|
521
|
+
const ids = [...new Set(portion.map((i) => p1[i].id))];
|
|
522
|
+
for (let b = 0; b < ids.length; b += batch) {
|
|
523
|
+
const part = ids.slice(b, b + batch);
|
|
524
|
+
for (const r of await conn.unsafe(`select x as id from unnest($1::uuid[]) x
|
|
525
|
+
where exists (select 1 from ${S}.entity e where e.id = x) or exists (select 1 from ${S}.log l where l.id = x)`, [part]))
|
|
526
|
+
taken.add(r.id);
|
|
527
|
+
}
|
|
528
|
+
for (const id of taken)
|
|
529
|
+
inLoad.delete(id);
|
|
530
|
+
report.skipped += taken.size;
|
|
531
|
+
todo = portion.filter((i) => !taken.has(p1[i].id));
|
|
532
|
+
}
|
|
533
|
+
if (opts.withHistory) {
|
|
534
|
+
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched);
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
for (let b = 0; b < portion.length; b += batch) {
|
|
538
|
+
const part = portion.slice(b, b + batch);
|
|
539
|
+
await writeBatch(conn, reg, part, inLoad, external, ts, xid, classesTouched, written, live, past);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (issues.any)
|
|
543
|
+
issues.fail();
|
|
544
|
+
// цели вне загрузки — один запрос в конце с блокировкой до фиксации (§2.6, п. 7)
|
|
545
|
+
const ext = [...external.keys()].sort();
|
|
546
|
+
report.external += ext.length;
|
|
547
|
+
if (ext.length) {
|
|
548
|
+
const found = new Map((await conn.unsafe(`select e.id, e.tenant, e.class from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id for key share`, [ext]))
|
|
549
|
+
.map((r) => [r.id, r]));
|
|
550
|
+
for (const id of ext) {
|
|
551
|
+
const ref = external.get(id);
|
|
552
|
+
const f = found.get(id);
|
|
553
|
+
if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
|
|
554
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: `цель конца ${ref.role} не найдена`, id });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// владельцы ($owner) — аккаунты: из загрузки (живые) или из базы, с блокировкой до фиксации
|
|
559
|
+
if (owners.size) {
|
|
560
|
+
const ids = [...owners.keys()].filter((id) => !(inLoad.get(id)?.cls === 'Account' && inLoad.get(id).alive)).sort();
|
|
561
|
+
const found = new Set(ids.length
|
|
562
|
+
? (await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[]) and e.class = 'Account' order by e.id for key share`, [ids])).map((r) => r.id)
|
|
563
|
+
: []);
|
|
564
|
+
for (const id of ids)
|
|
565
|
+
if (!found.has(id))
|
|
566
|
+
issues.add({ line: owners.get(id), code: 'target_not_found', message: `владелец ${id} не найден`, id });
|
|
567
|
+
}
|
|
568
|
+
// от имени пользователя цели ссылок должны быть ему видны (право READ): и из загрузки, и вне её
|
|
569
|
+
if (acl && targets.size) {
|
|
570
|
+
const ids = [...targets.keys()];
|
|
571
|
+
const hidden = await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[]) and e.class <> 'Account'
|
|
572
|
+
and not ${S}.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, $2::uuid, $3::uuid, $4::jsonb)`, [ids, tenant, asAcc, acl.read]);
|
|
573
|
+
for (const h of hidden) {
|
|
574
|
+
const t = targets.get(h.id);
|
|
575
|
+
issues.add({ line: t.line, code: 'target_not_found', message: `цель конца ${t.role} не найдена`, id: h.id });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// арендатор существует и включён
|
|
579
|
+
const [acc] = await conn.unsafe(`select coalesce((data->>'enabled')::boolean, true) as enabled from ${S}.entity where id = $1::uuid and class = 'Account'`, [tenant]);
|
|
580
|
+
if (!acc || !acc.enabled)
|
|
581
|
+
issues.add({ line: portion.length ? portion[0] + 1 : 0, code: 'tenant_denied', message: `арендатор ${tenant} не найден или отключён` });
|
|
582
|
+
if (issues.any)
|
|
583
|
+
issues.fail();
|
|
584
|
+
if (opts.verify && written.length)
|
|
585
|
+
await verifyRows(conn, reg, written);
|
|
586
|
+
if (classesTouched.size)
|
|
587
|
+
await conn.unsafe(`select pg_notify($1, c) from unnest($2::text[]) c`, [env.schema, [...classesTouched]]);
|
|
588
|
+
// строки прав меняют планы: пропуска прав клиентов — заново
|
|
589
|
+
if ([...classesTouched].some((c) => ACL_CLASSES.includes(c)))
|
|
590
|
+
await conn.unsafe(`select ${S}.acl_bump()`);
|
|
591
|
+
if (portion.length >= 10000)
|
|
592
|
+
await conn.unsafe(`analyze ${S}.entity; analyze ${S}.log`);
|
|
593
|
+
await conn.unsafe('commit');
|
|
594
|
+
}
|
|
595
|
+
catch (e) {
|
|
596
|
+
failed = e;
|
|
597
|
+
await conn.unsafe('rollback').catch(() => { });
|
|
598
|
+
}
|
|
599
|
+
finally {
|
|
600
|
+
conn.release();
|
|
601
|
+
}
|
|
602
|
+
if (failed) {
|
|
603
|
+
const err = failed;
|
|
604
|
+
if (err.code === '23505' && (err.constraint_name === 'entity_pkey' || err.constraint_name === 'log_id_rev')) {
|
|
605
|
+
await explainTaken(portion);
|
|
606
|
+
}
|
|
607
|
+
throw fromDbError(failed);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/** Порция не последняя (commitEvery): следующие порции загрузчик уже не проверит — нарушений может быть больше. */
|
|
611
|
+
function laterUnchecked(portion, to) {
|
|
612
|
+
if (portion !== portions[portions.length - 1])
|
|
613
|
+
to.unchecked.add('следующие порции не проверялись');
|
|
614
|
+
}
|
|
615
|
+
function isLastVersion(i) {
|
|
616
|
+
const list = versions.get(p1[i].id);
|
|
617
|
+
if (!list)
|
|
618
|
+
return true;
|
|
619
|
+
return list.reduce((m, k) => (Number(rows[k].$rev ?? 0) > Number(rows[m].$rev ?? 0) ? k : m), list[0]) === i;
|
|
620
|
+
}
|
|
621
|
+
/** Отказ первичного ключа или пары «id, rev»: какие id заняты — живые (exists) и удалённые раньше (deleted). */
|
|
622
|
+
async function explainTaken(portion) {
|
|
623
|
+
const ids = portion.map((i) => p1[i].id);
|
|
624
|
+
const res = await env.sql.unsafe(`select x as id, exists (select 1 from ${S}.entity e where e.id = x) as live,
|
|
625
|
+
exists (select 1 from ${S}.log l where l.id = x) as was
|
|
626
|
+
from unnest($1::uuid[]) x`, [ids]);
|
|
627
|
+
const taken = new Map(res.filter((r) => r.live || r.was).map((r) => [r.id, r.live ? 'exists' : 'deleted']));
|
|
628
|
+
const out = new Issues();
|
|
629
|
+
laterUnchecked(portion, out);
|
|
630
|
+
for (const i of portion) {
|
|
631
|
+
const c = taken.get(p1[i].id);
|
|
632
|
+
if (c)
|
|
633
|
+
out.add({ line: i + 1, code: c, message: c === 'exists' ? 'объект уже есть в базе' : 'объект с этим id жил раньше и удалён (existing: update воскресит)', id: p1[i].id, class: p1[i].cls.name });
|
|
634
|
+
}
|
|
635
|
+
if (out.any)
|
|
636
|
+
out.fail();
|
|
637
|
+
throw new LetopisError('exists', 'загрузка отменена: часть объектов уже есть в базе');
|
|
638
|
+
}
|
|
639
|
+
/** Проверки строки и ссылок; null — строка не пишется (нарушение уже в отчёте). */
|
|
640
|
+
function check(reg, i, inLoad, external, lastVersion) {
|
|
641
|
+
const raw = rows[i];
|
|
642
|
+
const cls = reg.find(p1[i].cls.name) ?? p1[i].cls;
|
|
643
|
+
const sh = shape(reg, tenant, cls, raw, true);
|
|
644
|
+
if (sh.error) {
|
|
645
|
+
issues.add({ line: i + 1, code: sh.error.code, message: sh.error.message, id: p1[i].id, class: cls.name, ...(sh.error.issues ? { issues: sh.error.issues } : {}) });
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
// аккаунты — только в System; членство лежит в арендаторе, в которого вступают (как в триггерах)
|
|
649
|
+
if (cls.name === 'Account' && tenant !== SYSTEM_ID) {
|
|
650
|
+
issues.add({ line: i + 1, code: 'tenant_denied', message: 'аккаунты лежат в арендаторе System', id: p1[i].id, class: cls.name });
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
if (cls.name === 'member' && sh.links.Tenant !== tenant) {
|
|
654
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: 'конец Tenant членства — арендатор строки', id: p1[i].id, class: cls.name });
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const tags = raw.$tags;
|
|
658
|
+
if (tags !== undefined && (!Array.isArray(tags) || !tags.every((x) => typeof x === 'string'))) {
|
|
659
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: '$tags — список строк', id: p1[i].id, class: cls.name });
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
if (tags)
|
|
663
|
+
sh.tags = tags;
|
|
664
|
+
const own = raw.$owner;
|
|
665
|
+
if (own !== undefined && own !== null) {
|
|
666
|
+
if (asAcc || typeof own !== 'string' || !UUID.test(own)) {
|
|
667
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: asAcc ? 'load({ as }): $owner не задаётся — владелец по ownerDefault класса' : '$owner — id аккаунта', id: p1[i].id, class: cls.name });
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
sh.owner = own.toLowerCase();
|
|
671
|
+
if (lastVersion && sh.owner !== tenant && !owners.has(sh.owner))
|
|
672
|
+
owners.set(sh.owner, i + 1);
|
|
673
|
+
}
|
|
674
|
+
// концы прошлых версий — часть снимка: их цели могли удалить позже (§2.6, п. 3)
|
|
675
|
+
if (!lastVersion)
|
|
676
|
+
return sh;
|
|
677
|
+
for (const [role, v] of Object.entries(sh.links)) {
|
|
678
|
+
const allowed = asList(cls.ends[role].class);
|
|
679
|
+
for (const id of asList(v)) {
|
|
680
|
+
if (acl && !targets.has(id))
|
|
681
|
+
targets.set(id, { line: i + 1, role });
|
|
682
|
+
const t = inLoad.get(id);
|
|
683
|
+
if (t) {
|
|
684
|
+
if (!t.alive || !accepts(reg, allowed, t.cls))
|
|
685
|
+
issues.add({ line: i + 1, code: 'target_not_found', message: `цель конца ${role} не найдена`, id: p1[i].id, class: cls.name });
|
|
686
|
+
}
|
|
687
|
+
else if (!external.has(id)) {
|
|
688
|
+
external.set(id, { line: i + 1, role, allowed });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return sh;
|
|
693
|
+
}
|
|
694
|
+
async function writeBatch(conn, reg, part, inLoad, external, ts, xid, touched, written, live, past) {
|
|
695
|
+
const ent = [];
|
|
696
|
+
const log = [];
|
|
697
|
+
const updates = [];
|
|
698
|
+
const rowsAcl = [];
|
|
699
|
+
for (const i of part) {
|
|
700
|
+
const { id, cls } = p1[i];
|
|
701
|
+
if (existing === 'skip' && (live.has(id) || past.has(id))) {
|
|
702
|
+
report.skipped++;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const sh = check(reg, i, inLoad, external, true);
|
|
706
|
+
if (!sh || issues.any)
|
|
707
|
+
continue;
|
|
708
|
+
if (live.has(id)) {
|
|
709
|
+
updates.push({ id, data: sh.data, links: sh.links, tags: sh.tags ?? null, line: i + 1 });
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const cur = reg.find(cls.name) ?? cls;
|
|
713
|
+
const prev = past.get(id);
|
|
714
|
+
const rev = prev ? prev.rev + 1 : 1;
|
|
715
|
+
// время воскрешённого объекта — не раньше его прошлой версии (монотонность, §2.10)
|
|
716
|
+
const atv = prev && Date.parse(prev.at) >= Date.parse(ts) ? new Date(Date.parse(prev.at) + 1).toISOString() : ts;
|
|
717
|
+
// владелец — $owner, по ownerDefault класса от имени пользователя, иначе арендатор (§2.10)
|
|
718
|
+
const owner = sh.owner ?? (asAcc && cur.ownerDefault === 'actor' ? asAcc : tenant);
|
|
719
|
+
const tags = sh.tags ?? [];
|
|
720
|
+
if (acl)
|
|
721
|
+
rowsAcl.push({ i, class: cls.name, id, owner, tags, data: sh.data, links: sh.links });
|
|
722
|
+
const common = [id, rev, cls.name, cur.rev, tenant, owner, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(tags), atv];
|
|
723
|
+
ent.push([...common, atv, author, agent, 'create', null, null, hex(prev?.hash), null].map(esc).join('\t') + '\n');
|
|
724
|
+
if (cur.history !== false)
|
|
725
|
+
log.push([...common, author, agent, 'create', null, null, hex(prev?.hash), xid].map(esc).join('\t') + '\n');
|
|
726
|
+
report.loaded++;
|
|
727
|
+
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
728
|
+
touched.add(cls.name);
|
|
729
|
+
written.push(id);
|
|
730
|
+
}
|
|
731
|
+
if (issues.any) {
|
|
732
|
+
if (aclRan)
|
|
733
|
+
issues.unchecked.add('условия ACL следующих пачек не проверялись');
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (acl) {
|
|
737
|
+
aclRan = true;
|
|
738
|
+
// условия ACL на строки — одним запросом на пачку: файл пользователя — не доверенные данные
|
|
739
|
+
const bad = await conn.unsafe(`select (x->>'i')::int as i from jsonb_array_elements($1::jsonb) x
|
|
740
|
+
where not ${S}.acl_row_ok($2::jsonb, x->>'class', (x->>'id')::uuid, (x->>'owner')::uuid, $3::uuid,
|
|
741
|
+
array(select jsonb_array_elements_text(x->'tags')), x->'data', x->'links')`, [rowsAcl, acl.write, tenant]);
|
|
742
|
+
for (const b of bad)
|
|
743
|
+
issues.add({ line: b.i + 1, code: 'acl_denied', message: 'строку запрещает условие ACL', id: p1[b.i].id });
|
|
744
|
+
if (updates.length) {
|
|
745
|
+
// existing: 'update' — условие на прежние значения и на новые (как политика правки)
|
|
746
|
+
const ids = updates.map((u) => u.id);
|
|
747
|
+
const old = await conn.unsafe(`select e.id from ${S}.entity e where e.id = any ($1::uuid[])
|
|
748
|
+
and not ${S}.acl_row_ok($2::jsonb, e.class, e.id, e.owner, e.tenant, e.tags, e.data, e.links)`, [ids, acl.write]);
|
|
749
|
+
const fresh = await conn.unsafe(`select u.id from jsonb_to_recordset($1::jsonb) as u(id uuid, data jsonb, links jsonb, tags text[])
|
|
750
|
+
join ${S}.entity e on e.id = u.id
|
|
751
|
+
where not ${S}.acl_row_ok($2::jsonb, e.class, e.id, e.owner, e.tenant, coalesce(u.tags, e.tags), u.data, u.links)`, [updates.map((u) => ({ id: u.id, data: u.data, links: u.links, tags: u.tags })), acl.write]);
|
|
752
|
+
const lineOfId = new Map(updates.map((u) => [u.id, u.line]));
|
|
753
|
+
for (const r of [...old, ...fresh])
|
|
754
|
+
issues.add({ line: lineOfId.get(r.id) ?? 0, code: 'acl_denied', message: 'правку строки запрещает условие ACL', id: r.id });
|
|
755
|
+
}
|
|
756
|
+
if (issues.any)
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
|
|
760
|
+
await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin`, log);
|
|
761
|
+
if (updates.length) {
|
|
762
|
+
// новая версия из заблокированных значений — одним оператором; без изменений — без версии
|
|
763
|
+
const res = await conn.unsafe(`with u as (select * from jsonb_to_recordset($1::jsonb) as x(id uuid, data jsonb, links jsonb, tags text[])),
|
|
764
|
+
up as (
|
|
765
|
+
update ${S}.entity e set data = u.data, links = u.links, tags = coalesce(u.tags, e.tags), rev = e.rev + 1, prev_hash = e.hash,
|
|
766
|
+
at = greatest($2::timestamptz, e.at + interval '1 microsecond'), author = $3::uuid, agent = $4::text, op = 'update',
|
|
767
|
+
reason = null, moved = null, cv = coalesce((${S}.class_row(e.class, e.tenant)).rev, e.cv)
|
|
768
|
+
from u where e.id = u.id and (e.data is distinct from u.data or e.links is distinct from u.links
|
|
769
|
+
or e.tags is distinct from coalesce(u.tags, e.tags))
|
|
770
|
+
returning e.*),
|
|
771
|
+
lg as (
|
|
772
|
+
insert into ${S}.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
773
|
+
select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash from up
|
|
774
|
+
where ${S}.history_on(up.class, up.tenant))
|
|
775
|
+
select up.id, up.class from up`, [updates.map((u) => ({ id: u.id, data: u.data, links: u.links, tags: u.tags })), ts, author, agent]);
|
|
776
|
+
report.updated += res.length;
|
|
777
|
+
report.skipped += updates.length - res.length;
|
|
778
|
+
for (const r of res) {
|
|
779
|
+
touched.add(r.class);
|
|
780
|
+
written.push(r.id);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
/** Режим истории: версии волнами — после каждой волны база отдаёт хэши, они prev_hash следующей. */
|
|
785
|
+
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched) {
|
|
786
|
+
const byId = new Map();
|
|
787
|
+
for (const i of portion) {
|
|
788
|
+
const id = p1[i].id;
|
|
789
|
+
if (!byId.has(id))
|
|
790
|
+
byId.set(id, []);
|
|
791
|
+
byId.get(id).push(i);
|
|
792
|
+
}
|
|
793
|
+
const lists = [...byId.values()].map((l) => l.sort((a, b) => Number(rows[a].$rev) - Number(rows[b].$rev)));
|
|
794
|
+
for (const l of lists) {
|
|
795
|
+
for (let k = 0; k < l.length; k++) {
|
|
796
|
+
const r = rows[l[k]];
|
|
797
|
+
if (Number(r.$rev) !== k + 1 || typeof r.$at !== 'string') {
|
|
798
|
+
issues.add({ line: l[k] + 1, code: 'invalid_data', message: 'режим истории: версии с $rev подряд от 1 и $at — время версии', id: p1[l[k]].id });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const shaped = new Map();
|
|
803
|
+
for (const l of lists)
|
|
804
|
+
l.forEach((i, k) => {
|
|
805
|
+
const sh = check(reg, i, inLoad, external, k === l.length - 1);
|
|
806
|
+
if (sh)
|
|
807
|
+
shaped.set(i, sh);
|
|
808
|
+
});
|
|
809
|
+
if (issues.any)
|
|
810
|
+
return;
|
|
811
|
+
// без разворота в аргументы: на сотнях тысяч объектов он переполняет стек
|
|
812
|
+
const waves = lists.reduce((m, l) => Math.max(m, l.length), 0);
|
|
813
|
+
const prevHash = new Map();
|
|
814
|
+
const lastPrev = new Map();
|
|
815
|
+
// волна — одна вставка с returning hash: хэши волны сразу становятся prev_hash следующей (мелкий
|
|
816
|
+
// COPY плюс отдельный запрос хэшей стоил по задержке TCP на волну)
|
|
817
|
+
for (let w = 0; w < waves; w++) {
|
|
818
|
+
const items = [];
|
|
819
|
+
for (const l of lists) {
|
|
820
|
+
const i = l[w];
|
|
821
|
+
if (i === undefined)
|
|
822
|
+
continue;
|
|
823
|
+
const { id, cls } = p1[i];
|
|
824
|
+
const cur = reg.find(cls.name) ?? cls;
|
|
825
|
+
if (cur.history === false)
|
|
826
|
+
continue;
|
|
827
|
+
const r = rows[i];
|
|
828
|
+
const sh = shaped.get(i);
|
|
829
|
+
const op = typeof r.$op === 'string' ? r.$op : w === 0 ? 'create' : 'update';
|
|
830
|
+
const ph = prevHash.get(id) ?? null;
|
|
831
|
+
if (w === l.length - 1)
|
|
832
|
+
lastPrev.set(id, ph);
|
|
833
|
+
items.push({ id, rev: w + 1, class: cls.name, cv: cur.rev, tenant, owner: sh.owner ?? tenant, tags: sh.tags ?? [], links: sh.links,
|
|
834
|
+
data: sh.data, at: r.$at, author: SYSTEM_ID, agent, op, ph: ph ? ph.toString('hex') : null, tx: xid });
|
|
835
|
+
touched.add(cls.name);
|
|
836
|
+
}
|
|
837
|
+
for (let k = 0; k < items.length; k += 5000) {
|
|
838
|
+
const res = await conn.unsafe(`insert into ${S}.log (${LOG_COLS})
|
|
839
|
+
select x.id, x.rev, x.class, x.cv, x.tenant, x.owner, x.links, x.data, x.tags, x.at, x.author, x.agent, x.op, null, null,
|
|
840
|
+
decode(x.ph, 'hex'), x.tx::xid8
|
|
841
|
+
from jsonb_to_recordset($1::jsonb) as x(id uuid, rev int, class text, cv int, tenant uuid, owner uuid, tags text[], links jsonb,
|
|
842
|
+
data jsonb, at timestamptz, author uuid, agent text, op text, ph text, tx text)
|
|
843
|
+
returning id, hash`, [items.slice(k, k + 5000)]);
|
|
844
|
+
if (w < waves - 1)
|
|
845
|
+
for (const h of res)
|
|
846
|
+
prevHash.set(h.id, h.hash);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// текущее состояние — последняя версия, если она не надгробие; created_at — время первой версии
|
|
850
|
+
const ent = [];
|
|
851
|
+
for (const l of lists) {
|
|
852
|
+
const last = l[l.length - 1];
|
|
853
|
+
const r = rows[last];
|
|
854
|
+
if (r.$op === 'delete')
|
|
855
|
+
continue;
|
|
856
|
+
const { id, cls } = p1[last];
|
|
857
|
+
const cur = reg.find(cls.name) ?? cls;
|
|
858
|
+
const sh = shaped.get(last);
|
|
859
|
+
const op = typeof r.$op === 'string' ? r.$op : l.length === 1 ? 'create' : 'update';
|
|
860
|
+
ent.push([id, l.length, cls.name, cur.rev, tenant, sh.owner ?? tenant, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(sh.tags ?? []), r.$at,
|
|
861
|
+
rows[l[0]].$at, SYSTEM_ID, agent, op, null, null, hex(cur.history === false ? null : lastPrev.get(id) ?? null), null].map(esc).join('\t') + '\n');
|
|
862
|
+
report.loaded++;
|
|
863
|
+
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
864
|
+
}
|
|
865
|
+
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin`, ent);
|
|
866
|
+
}
|
|
867
|
+
/** verify: true — загруженные строки перепроверяет проверка базы (данные и цели концов). */
|
|
868
|
+
async function verifyRows(conn, reg, ids) {
|
|
869
|
+
const bad = await conn.unsafe(`select e.id, e.class, ${S}.validate_data(c.data->'resolved'->'rules', c.validator, c.data->'resolved'->'vars', e.data) as issues
|
|
870
|
+
from ${S}.entity e cross join lateral ${S}.class_row(e.class, e.tenant) c
|
|
871
|
+
where e.id = any ($1::uuid[]) and ${S}.validate_data(c.data->'resolved'->'rules', c.validator, c.data->'resolved'->'vars', e.data) <> '[]'::jsonb limit ${ISSUES_LIMIT}`, [ids]);
|
|
872
|
+
for (const r of bad)
|
|
873
|
+
issues.add({ line: 0, code: 'invalid_data', message: `проверка базы: строка ${r.id} не проходит описание класса ${r.class}`, id: r.id, issues: r.issues });
|
|
874
|
+
if (bad.length >= ISSUES_LIMIT)
|
|
875
|
+
issues.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
|
|
876
|
+
const [t] = await conn.unsafe(`select ${S}.targets_bad((select jsonb_agg(jsonb_build_object('class', e.class, 'tenant', e.tenant, 'links', e.links))
|
|
877
|
+
from ${S}.entity e where e.id = any ($1::uuid[]) and e.links <> '{}'::jsonb), false) as role`, [ids]);
|
|
878
|
+
if (t?.role)
|
|
879
|
+
issues.add({ line: 0, code: 'target_not_found', message: `проверка базы: цель конца ${t.role} не найдена` });
|
|
880
|
+
void reg;
|
|
881
|
+
if (issues.any)
|
|
882
|
+
issues.fail();
|
|
883
|
+
}
|
|
884
|
+
}
|