letopis 1.4.2 → 2.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.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/load.js
CHANGED
|
@@ -18,17 +18,25 @@
|
|
|
18
18
|
* полиморфного конца — с $class цели. Ключи на $ внутри данных — обычные данные.
|
|
19
19
|
*/
|
|
20
20
|
import { randomUUID } from 'node:crypto';
|
|
21
|
-
import { LetopisError, fromDbError,
|
|
21
|
+
import { LetopisError, checkOptions, fromDbError, issuesFromDb, lerr, lmsg } from './errors.js';
|
|
22
22
|
import { isOp } from './ops.js';
|
|
23
|
-
import { Registry
|
|
23
|
+
import { Registry } from './registry.js';
|
|
24
24
|
import { accepts } from './sql.js';
|
|
25
25
|
import { v5Id } from './uuid.js';
|
|
26
|
-
import { applyDefaults } from './validate.js';
|
|
26
|
+
import { applyDefaults, leavesToDb } from './validate.js';
|
|
27
27
|
import { isInc } from './write.js';
|
|
28
28
|
import { ACL_CLASS_NAMES } from './types.js';
|
|
29
29
|
import { isConnectionError, release, rollbackAfter } from './tx.js';
|
|
30
30
|
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
31
31
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
/**
|
|
33
|
+
* Все опции load(): неизвестная — invalid_data с похожими (checkOptions; план 2.0, 7.10 №4). Фасад db.load() сверяет их
|
|
34
|
+
* до пула администратора (index.ts), load() — ещё раз до проверки прав.
|
|
35
|
+
*/
|
|
36
|
+
export const LOAD_OPTIONS = {
|
|
37
|
+
duplicates: true, existing: true, batch: true, commitEvery: true, withHistory: true, class: true, tenant: true,
|
|
38
|
+
agent: true, as: true, initial: true,
|
|
39
|
+
};
|
|
32
40
|
/** Классы, строки которых первая заливка сохраняет и возвращает: описания классов, аккаунты, членства, права. */
|
|
33
41
|
const SYSTEM_CLASSES = ['Class', 'Account', 'member', 'Resource', 'rule'];
|
|
34
42
|
// значения без обратной косой, табуляции и переводов строки (почти все: uuid, числа, время) — как есть, без четырёх
|
|
@@ -40,9 +48,8 @@ const esc = (v) => {
|
|
|
40
48
|
const s = String(v);
|
|
41
49
|
return ESCAPED.test(s) ? s.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r') : s;
|
|
42
50
|
};
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const LOG_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, tx';
|
|
51
|
+
const ENTITY_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, validator';
|
|
52
|
+
const LOG_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, tx';
|
|
46
53
|
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
47
54
|
/** Теги → литерал text[] для COPY (экранирование COPY — потом, в esc). */
|
|
48
55
|
const pgArray = (tags) => `{${tags.map((t) => `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(',')}}`;
|
|
@@ -72,8 +79,11 @@ class Issues {
|
|
|
72
79
|
const first = this.list[0];
|
|
73
80
|
const rest = this.total - 1;
|
|
74
81
|
const why = [...this.unchecked].join('; ');
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
// форма текста — по полноте проверки и числу остальных нарушений: четыре записи каталога, а не склейка
|
|
83
|
+
const key = why ? (rest ? 'write.load_cancelled_at_least' : 'write.load_cancelled_maybe_more') : rest ? 'write.load_cancelled_more' : 'write.load_cancelled';
|
|
84
|
+
const params = { line: first.line, message: first.message, rest, why };
|
|
85
|
+
// код ошибки — код первого нарушения, а не записи каталога (поэтому не lerr): ключ и параметры — как у fromDbError
|
|
86
|
+
throw new LetopisError(first.code, lmsg(key, params), { rows: this.list, total: this.total, truncated: this.total > this.list.length, limit: ISSUES_LIMIT, complete: !why }, { key, params });
|
|
77
87
|
}
|
|
78
88
|
}
|
|
79
89
|
/** Вход → доступ к строке по номеру (массивы и объект «класс → строки» — без копии). */
|
|
@@ -92,32 +102,32 @@ async function toList(input, cls) {
|
|
|
92
102
|
}
|
|
93
103
|
if (isObj(input))
|
|
94
104
|
return Object.entries(input).flatMap(([c, rows]) => tag(rows, c));
|
|
95
|
-
throw
|
|
105
|
+
throw lerr('write.load_bad_input');
|
|
96
106
|
}
|
|
97
107
|
/** Ссылка на цель: id, { $key: […] } или { $ext } (у полиморфного конца — с $class). */
|
|
98
108
|
function refOf(reg, tenant, end, v) {
|
|
99
109
|
if (typeof v === 'string')
|
|
100
|
-
return UUID.test(v) ? { id: v.toLowerCase() } : { error: '
|
|
110
|
+
return UUID.test(v) ? { id: v.toLowerCase() } : { error: lmsg('write.load_end_not_uuid') };
|
|
101
111
|
if (!isObj(v) || (!('$key' in v) && !('$ext' in v)))
|
|
102
|
-
return { error: '
|
|
112
|
+
return { error: lmsg('write.load_end_value') };
|
|
103
113
|
const classes = asList(end.class);
|
|
104
114
|
const name = typeof v.$class === 'string' ? v.$class : classes.length === 1 ? classes[0] : null;
|
|
105
115
|
if (!name)
|
|
106
|
-
return { error: '
|
|
116
|
+
return { error: lmsg('write.load_end_polymorphic') };
|
|
107
117
|
const c = reg.find(name);
|
|
108
118
|
if (!c || c.abstract)
|
|
109
|
-
return { error:
|
|
119
|
+
return { error: lmsg('write.load_target_class', { name }) };
|
|
110
120
|
if ('$key' in v) {
|
|
111
121
|
const vals = v.$key;
|
|
112
122
|
if (!Array.isArray(vals) || vals.length !== c.key.length || !vals.every((x) => typeof x === 'string')) {
|
|
113
|
-
return { error:
|
|
123
|
+
return { error: lmsg('write.load_target_key', { cls: c.name, key: c.key.join(', ') }) };
|
|
114
124
|
}
|
|
115
125
|
return { id: v5Id(tenant, c.name, vals.map((x, i) => (c.ends[c.key[i]] ? x.toLowerCase() : x))) };
|
|
116
126
|
}
|
|
117
127
|
if (c.key.length)
|
|
118
|
-
return { error:
|
|
128
|
+
return { error: lmsg('write.load_ext_keyed', { cls: c.name, key: c.key.join(', ') }) };
|
|
119
129
|
if (typeof v.$ext !== 'string')
|
|
120
|
-
return { error: '
|
|
130
|
+
return { error: lmsg('write.load_ext_string') };
|
|
121
131
|
return { id: v5Id(tenant, c.name, [v.$ext]) };
|
|
122
132
|
}
|
|
123
133
|
/** Строка → данные и концы: поля верхнего уровня, роли, пометки на $; проверки по описанию класса. */
|
|
@@ -133,14 +143,14 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
133
143
|
const end = cls.ends[k];
|
|
134
144
|
if (end.many) {
|
|
135
145
|
if (!Array.isArray(v))
|
|
136
|
-
return { data, links, error: { code: 'invalid_data', message:
|
|
146
|
+
return { data, links, error: { code: 'invalid_data', message: lmsg('write.load_end_list', { role: k }) } };
|
|
137
147
|
const ids = [];
|
|
138
148
|
for (const x of v) {
|
|
139
149
|
const r = refOf(reg, tenant, end, x);
|
|
140
150
|
if ('error' in r)
|
|
141
|
-
return { data, links, error: { code: 'invalid_data', message:
|
|
151
|
+
return { data, links, error: { code: 'invalid_data', message: lmsg('write.load_end_ref', { role: k, error: r.error }) } };
|
|
142
152
|
if (ids.includes(r.id))
|
|
143
|
-
return { data, links, error: { code: 'invalid_data', message:
|
|
153
|
+
return { data, links, error: { code: 'invalid_data', message: lmsg('write.load_end_repeat', { role: k }) } };
|
|
144
154
|
ids.push(r.id);
|
|
145
155
|
}
|
|
146
156
|
links[k] = ids;
|
|
@@ -148,7 +158,7 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
148
158
|
else {
|
|
149
159
|
const r = refOf(reg, tenant, end, v);
|
|
150
160
|
if ('error' in r)
|
|
151
|
-
return { data, links, error: { code: 'invalid_data', message:
|
|
161
|
+
return { data, links, error: { code: 'invalid_data', message: lmsg('write.load_end_ref', { role: k, error: r.error }) } };
|
|
152
162
|
links[k] = r.id;
|
|
153
163
|
}
|
|
154
164
|
}
|
|
@@ -156,17 +166,13 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
156
166
|
data[k] = v;
|
|
157
167
|
}
|
|
158
168
|
}
|
|
159
|
-
// агрегатные поля считает база (план 1.1, §2.4): переданное значение не пишется — 0, итог посчитает триггер
|
|
160
|
-
// после оператора, а первая заливка — agg_fill
|
|
161
|
-
for (const f of aggregateFields(cls))
|
|
162
|
-
data[f] = 0;
|
|
163
169
|
if (!check)
|
|
164
170
|
return { data, links };
|
|
165
171
|
const scan = (v, path) => {
|
|
166
172
|
if (isInc(v))
|
|
167
|
-
return
|
|
173
|
+
return lmsg('write.load_inc', { path });
|
|
168
174
|
if (isOp(v))
|
|
169
|
-
return
|
|
175
|
+
return lmsg('write.load_filter_op', { path });
|
|
170
176
|
if (Array.isArray(v)) {
|
|
171
177
|
for (let i = 0; i < v.length; i++) {
|
|
172
178
|
const m = scan(v[i], `${path}.${i}`);
|
|
@@ -189,14 +195,14 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
189
195
|
const full = applyDefaults(cls.schema, data);
|
|
190
196
|
const issues = cls.validator()(full);
|
|
191
197
|
if (issues.length)
|
|
192
|
-
return { data: full, links, error: { code: 'invalid_data', message:
|
|
198
|
+
return { data: full, links, error: { code: 'invalid_data', message: lmsg('write.load_invalid_data', { cls: cls.name }), issues } };
|
|
193
199
|
for (const [role, end] of Object.entries(cls.ends)) {
|
|
194
200
|
const n = end.many ? (links[role]?.length ?? 0) : links[role] ? 1 : 0;
|
|
195
201
|
const min = Math.max(end.min ?? 0, end.required ? 1 : 0);
|
|
196
202
|
if (end.many ? n < min : end.required && !n)
|
|
197
|
-
return { data: full, links, error: { code: 'invalid_data', message: end.many ?
|
|
203
|
+
return { data: full, links, error: { code: 'invalid_data', message: lmsg(end.many ? 'write.load_end_too_few' : 'write.load_end_required', { role }) } };
|
|
198
204
|
if (end.many && end.max !== undefined && n > end.max)
|
|
199
|
-
return { data: full, links, error: { code: 'invalid_data', message:
|
|
205
|
+
return { data: full, links, error: { code: 'invalid_data', message: lmsg('write.load_end_too_many', { role }) } };
|
|
200
206
|
}
|
|
201
207
|
return { data: full, links };
|
|
202
208
|
}
|
|
@@ -204,7 +210,7 @@ function shape(reg, tenant, cls, raw, check) {
|
|
|
204
210
|
function idOfRow(reg, tenant, cls, raw) {
|
|
205
211
|
if (!cls.key.length) {
|
|
206
212
|
if (raw.$ext !== undefined)
|
|
207
|
-
return typeof raw.$ext === 'string' ? v5Id(tenant, cls.name, [raw.$ext]) : { error: '
|
|
213
|
+
return typeof raw.$ext === 'string' ? v5Id(tenant, cls.name, [raw.$ext]) : { error: lmsg('write.load_ext_string') };
|
|
208
214
|
return v5Id(tenant, cls.name, [randomUUID()]);
|
|
209
215
|
}
|
|
210
216
|
const vals = [];
|
|
@@ -212,13 +218,13 @@ function idOfRow(reg, tenant, cls, raw) {
|
|
|
212
218
|
if (cls.ends[k]) {
|
|
213
219
|
const r = refOf(reg, tenant, cls.ends[k], raw[k]);
|
|
214
220
|
if ('error' in r)
|
|
215
|
-
return { error:
|
|
221
|
+
return { error: lmsg('write.load_key_part_ref', { part: k, error: r.error }) };
|
|
216
222
|
vals.push(r.id);
|
|
217
223
|
}
|
|
218
224
|
else {
|
|
219
225
|
const v = raw[k] ?? cls.schema.properties?.[k]?.default;
|
|
220
226
|
if (typeof v !== 'string')
|
|
221
|
-
return { error:
|
|
227
|
+
return { error: lmsg('write.load_key_part_string', { part: k }) };
|
|
222
228
|
vals.push(v);
|
|
223
229
|
}
|
|
224
230
|
}
|
|
@@ -320,23 +326,6 @@ async function copyLines(conn, text, lines) {
|
|
|
320
326
|
pump();
|
|
321
327
|
});
|
|
322
328
|
}
|
|
323
|
-
/** verify: true — загруженные строки перепроверяет проверка базы (данные и цели концов); нарушения — в to. */
|
|
324
|
-
async function verifyIds(conn, S, ids, to) {
|
|
325
|
-
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
|
|
326
|
-
from ${S}.entity e cross join lateral ${S}.class_row(e.class, e.tenant) c
|
|
327
|
-
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]);
|
|
328
|
-
for (const r of bad)
|
|
329
|
-
to.add({ line: 0, code: 'invalid_data', message: `проверка базы: строка ${r.id} не проходит описание класса ${r.class}`, id: r.id, issues: r.issues });
|
|
330
|
-
if (bad.length >= ISSUES_LIMIT)
|
|
331
|
-
to.unchecked.add(`проверка базы называет не больше ${ISSUES_LIMIT} строк`);
|
|
332
|
-
const [t] = await conn.unsafe(`select ${S}.targets_bad((select jsonb_agg(jsonb_build_object('class', e.class, 'tenant', e.tenant, 'links', e.links))
|
|
333
|
-
from ${S}.entity e where e.id = any ($1::uuid[]) and e.links <> '{}'::jsonb), false) as role`, [ids]);
|
|
334
|
-
const role = t?.role;
|
|
335
|
-
if (role?.startsWith('!'))
|
|
336
|
-
to.add({ line: 0, code: 'derived_class', message: `проверка базы: конец ${role.slice(1)} ведёт на строку производного класса` });
|
|
337
|
-
else if (role)
|
|
338
|
-
to.add({ line: 0, code: 'target_not_found', message: `проверка базы: цель конца ${role} не найдена` });
|
|
339
|
-
}
|
|
340
329
|
/**
|
|
341
330
|
* Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
|
|
342
331
|
* разделяемыми блокировками семейств, проверки в коде, COPY пачками, запрос по целям вне загрузки,
|
|
@@ -344,30 +333,29 @@ async function verifyIds(conn, S, ids, to) {
|
|
|
344
333
|
* называет строки и нарушения.
|
|
345
334
|
*/
|
|
346
335
|
export async function load(env, input, opts = {}) {
|
|
347
|
-
|
|
348
|
-
throw removed('load({ trust })', 'запрос по целям вне загрузки стоит 0,1–1,3 % её времени — режим доверия убран (решение 2 точки А)');
|
|
336
|
+
checkOptions('load', opts, LOAD_OPTIONS);
|
|
349
337
|
if (!env.admin)
|
|
350
|
-
throw
|
|
338
|
+
throw lerr('write.load_admin_required');
|
|
351
339
|
const asAcc = opts.as;
|
|
352
340
|
if (asAcc !== undefined && (typeof asAcc !== 'string' || !UUID.test(asAcc)))
|
|
353
|
-
throw
|
|
341
|
+
throw lerr('write.load_as_not_uuid');
|
|
354
342
|
if (asAcc !== undefined && opts.withHistory)
|
|
355
|
-
throw
|
|
343
|
+
throw lerr('write.load_as_history');
|
|
356
344
|
if (opts.withHistory && opts.existing === 'update')
|
|
357
|
-
throw
|
|
345
|
+
throw lerr('write.load_history_update');
|
|
358
346
|
const ini = env.session ?? null;
|
|
359
347
|
if (opts.initial || ini) {
|
|
360
348
|
// снятые индексы и TRUNCATE — одна транзакция; в пустой схеме пропускать и обновлять нечего
|
|
361
349
|
if (opts.commitEvery)
|
|
362
|
-
throw
|
|
350
|
+
throw lerr('write.load_initial_commit_every');
|
|
363
351
|
if (opts.existing !== undefined && opts.existing !== 'error')
|
|
364
|
-
throw
|
|
352
|
+
throw lerr('write.load_initial_existing');
|
|
365
353
|
if (asAcc !== undefined)
|
|
366
|
-
throw
|
|
354
|
+
throw lerr('write.load_initial_as');
|
|
367
355
|
}
|
|
368
356
|
const tenant = opts.tenant ?? asAcc ?? env.tenant;
|
|
369
357
|
if (!tenant)
|
|
370
|
-
throw
|
|
358
|
+
throw lerr('write.load_no_tenant');
|
|
371
359
|
const dupMode = opts.duplicates ?? 'error';
|
|
372
360
|
const existing = opts.existing ?? 'error';
|
|
373
361
|
const batch = opts.batch ?? 10000;
|
|
@@ -413,23 +401,17 @@ export async function load(env, input, opts = {}) {
|
|
|
413
401
|
const name = raw?.$class;
|
|
414
402
|
const cls = typeof name === 'string' ? reg0.find(name) : undefined;
|
|
415
403
|
if (!cls) {
|
|
416
|
-
issues.add({ line, code: 'invalid_class', message: typeof name === 'string' ?
|
|
404
|
+
issues.add({ line, code: 'invalid_class', message: typeof name === 'string' ? lmsg('write.load_class_not_found', { name }) : lmsg('write.load_no_class') });
|
|
417
405
|
p1[i] = null;
|
|
418
406
|
continue;
|
|
419
407
|
}
|
|
420
408
|
if (cls.name === 'Class') {
|
|
421
|
-
issues.add({ line, code: 'class_not_loadable', message: '
|
|
409
|
+
issues.add({ line, code: 'class_not_loadable', message: lmsg('write.load_class_rows'), class: cls.name });
|
|
422
410
|
p1[i] = null;
|
|
423
411
|
continue;
|
|
424
412
|
}
|
|
425
413
|
if (cls.abstract) {
|
|
426
|
-
issues.add({ line, code: 'invalid_class', message:
|
|
427
|
-
p1[i] = null;
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
// строки производного класса пишет только его проектор (план 1.1, §2.4)
|
|
431
|
-
if (cls.derivedBy) {
|
|
432
|
-
issues.add({ line, code: 'derived_class', message: `класс ${cls.name} производный: его строки пишет проектор ${cls.derivedBy}`, class: cls.name });
|
|
414
|
+
issues.add({ line, code: 'invalid_class', message: lmsg('write.load_class_abstract', { cls: cls.name }), class: cls.name });
|
|
433
415
|
p1[i] = null;
|
|
434
416
|
continue;
|
|
435
417
|
}
|
|
@@ -441,7 +423,7 @@ export async function load(env, input, opts = {}) {
|
|
|
441
423
|
}
|
|
442
424
|
// первая заливка: первичного ключа нет — повтор id со строками системных классов и прошлых загрузок сессии ловит проход 1
|
|
443
425
|
if (ini?.ids.has(id)) {
|
|
444
|
-
issues.add({ line, code: 'exists', message: '
|
|
426
|
+
issues.add({ line, code: 'exists', message: lmsg('write.load_taken'), id, class: cls.name });
|
|
445
427
|
p1[i] = null;
|
|
446
428
|
continue;
|
|
447
429
|
}
|
|
@@ -459,7 +441,7 @@ export async function load(env, input, opts = {}) {
|
|
|
459
441
|
chosen.add(i);
|
|
460
442
|
}
|
|
461
443
|
else if (dupMode === 'error') {
|
|
462
|
-
issues.add({ line, code: 'duplicate', message:
|
|
444
|
+
issues.add({ line, code: 'duplicate', message: lmsg('write.load_duplicate', { id, prev: prev + 1 }), id, class: cls.name });
|
|
463
445
|
}
|
|
464
446
|
else {
|
|
465
447
|
report.duplicates++;
|
|
@@ -528,7 +510,6 @@ export async function load(env, input, opts = {}) {
|
|
|
528
510
|
laterUnchecked(portion, issues);
|
|
529
511
|
// первая заливка: транзакцию, роль и пояс поставила сессия, фиксирует и откатывает тоже она
|
|
530
512
|
const conn = ini ? ini.conn : await env.sql.reserve();
|
|
531
|
-
const written = [];
|
|
532
513
|
let failed = null;
|
|
533
514
|
try {
|
|
534
515
|
if (!ini) {
|
|
@@ -552,7 +533,7 @@ export async function load(env, input, opts = {}) {
|
|
|
552
533
|
const n = p1[i].cls.name;
|
|
553
534
|
if (!(n in acl.write) && !denied.has(n)) {
|
|
554
535
|
denied.add(n);
|
|
555
|
-
issues.add({ line: i + 1, code: 'acl_denied', message:
|
|
536
|
+
issues.add({ line: i + 1, code: 'acl_denied', message: lmsg('write.load_no_write', { cls: n }), class: n });
|
|
556
537
|
}
|
|
557
538
|
}
|
|
558
539
|
if (issues.any)
|
|
@@ -578,7 +559,7 @@ export async function load(env, input, opts = {}) {
|
|
|
578
559
|
const lock = existing === 'update' ? ' order by e.id for no key update' : '';
|
|
579
560
|
for (const r of await conn.unsafe(`select e.id, e.class from ${S}.entity e where e.id = any ($1::uuid[])${lock}`, [ids]))
|
|
580
561
|
live.set(r.id, r.class);
|
|
581
|
-
for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.
|
|
562
|
+
for (const r of await conn.unsafe(`select distinct on (l.id) l.id, l.rev, l.at::text as at from ${S}.log l
|
|
582
563
|
where l.id = any ($1::uuid[]) order by l.id, l.rev desc`, [ids]))
|
|
583
564
|
past.set(r.id, r);
|
|
584
565
|
}
|
|
@@ -604,23 +585,23 @@ export async function load(env, input, opts = {}) {
|
|
|
604
585
|
todo = portion.filter((i) => !taken.has(p1[i].id));
|
|
605
586
|
}
|
|
606
587
|
if (opts.withHistory) {
|
|
607
|
-
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched
|
|
588
|
+
await historyWrite(conn, reg, todo, inLoad, external, xid, classesTouched);
|
|
608
589
|
}
|
|
609
590
|
else if (ini) {
|
|
610
|
-
await pipeline(conn, reg, portion, inLoad, external, ts, xid, classesTouched,
|
|
591
|
+
await pipeline(conn, reg, portion, inLoad, external, ts, xid, classesTouched, live, past);
|
|
611
592
|
}
|
|
612
593
|
else {
|
|
613
594
|
for (let b = 0; b < portion.length; b += batch) {
|
|
614
595
|
const part = portion.slice(b, b + batch);
|
|
615
|
-
await writeBatch(conn, reg, part, inLoad, external, ts, xid, classesTouched,
|
|
596
|
+
await writeBatch(conn, reg, part, inLoad, external, ts, xid, classesTouched, live, past);
|
|
616
597
|
}
|
|
617
598
|
}
|
|
618
599
|
if (issues.any)
|
|
619
600
|
issues.fail();
|
|
620
601
|
if (ini) {
|
|
621
|
-
// цели,
|
|
602
|
+
// цели, владельцы и арендатор — в конце сессии, по перестроенным индексам
|
|
622
603
|
report.external += external.size;
|
|
623
|
-
ini.pending.push({ reg, tenant: tenant, external, owners: new Map(owners)
|
|
604
|
+
ini.pending.push({ reg, tenant: tenant, external, owners: new Map(owners) });
|
|
624
605
|
for (const id of inLoad.keys())
|
|
625
606
|
ini.ids.add(id);
|
|
626
607
|
let set = ini.touched.get(tenant);
|
|
@@ -640,10 +621,7 @@ export async function load(env, input, opts = {}) {
|
|
|
640
621
|
const ref = external.get(id);
|
|
641
622
|
const f = found.get(id);
|
|
642
623
|
if (!f || (f.tenant !== tenant && f.tenant !== SYSTEM_ID) || !accepts(reg, ref.allowed, f.class)) {
|
|
643
|
-
issues.add({ line: ref.line, code: 'target_not_found', message:
|
|
644
|
-
}
|
|
645
|
-
else if (reg.find(f.class)?.derivedBy) {
|
|
646
|
-
issues.add({ line: ref.line, code: 'derived_class', message: `цель конца ${ref.role} — строка производного класса ${f.class}`, id });
|
|
624
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
|
|
647
625
|
}
|
|
648
626
|
}
|
|
649
627
|
}
|
|
@@ -655,7 +633,7 @@ export async function load(env, input, opts = {}) {
|
|
|
655
633
|
: []);
|
|
656
634
|
for (const id of ids)
|
|
657
635
|
if (!found.has(id))
|
|
658
|
-
issues.add({ line: owners.get(id), code: 'target_not_found', message:
|
|
636
|
+
issues.add({ line: owners.get(id), code: 'target_not_found', message: lmsg('write.load_owner_not_found', { id }), id });
|
|
659
637
|
}
|
|
660
638
|
// от имени пользователя цели ссылок должны быть ему видны (право READ): и из загрузки, и вне её
|
|
661
639
|
if (acl && targets.size) {
|
|
@@ -664,20 +642,15 @@ export async function load(env, input, opts = {}) {
|
|
|
664
642
|
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]);
|
|
665
643
|
for (const h of hidden) {
|
|
666
644
|
const t = targets.get(h.id);
|
|
667
|
-
issues.add({ line: t.line, code: 'target_not_found', message:
|
|
645
|
+
issues.add({ line: t.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: t.role }), id: h.id });
|
|
668
646
|
}
|
|
669
647
|
}
|
|
670
648
|
// арендатор существует и включён
|
|
671
649
|
const [acc] = await conn.unsafe(`select coalesce((data->>'enabled')::boolean, true) as enabled from ${S}.entity where id = $1::uuid and class = 'Account'`, [tenant]);
|
|
672
650
|
if (!acc || !acc.enabled)
|
|
673
|
-
issues.add({ line: portion.length ? portion[0] + 1 : 0, code: 'tenant_denied', message:
|
|
651
|
+
issues.add({ line: portion.length ? portion[0] + 1 : 0, code: 'tenant_denied', message: lmsg('write.load_tenant_denied', { tenant }) });
|
|
674
652
|
if (issues.any)
|
|
675
653
|
issues.fail();
|
|
676
|
-
if (opts.verify && written.length) {
|
|
677
|
-
await verifyIds(conn, S, written, issues);
|
|
678
|
-
if (issues.any)
|
|
679
|
-
issues.fail();
|
|
680
|
-
}
|
|
681
654
|
// сигнал — функцией базы, как у триггеров: нагрузка по режиму сигналов установки (план 1.1)
|
|
682
655
|
if (classesTouched.size) {
|
|
683
656
|
const touched = [...classesTouched];
|
|
@@ -711,7 +684,7 @@ export async function load(env, input, opts = {}) {
|
|
|
711
684
|
/** Порция не последняя (commitEvery): следующие порции загрузчик уже не проверит — нарушений может быть больше. */
|
|
712
685
|
function laterUnchecked(portion, to) {
|
|
713
686
|
if (portion !== portions[portions.length - 1])
|
|
714
|
-
to.unchecked.add('
|
|
687
|
+
to.unchecked.add(lmsg('write.load_later_portions'));
|
|
715
688
|
}
|
|
716
689
|
function isLastVersion(i) {
|
|
717
690
|
const list = versions.get(p1[i].id);
|
|
@@ -731,11 +704,11 @@ export async function load(env, input, opts = {}) {
|
|
|
731
704
|
for (const i of portion) {
|
|
732
705
|
const c = taken.get(p1[i].id);
|
|
733
706
|
if (c)
|
|
734
|
-
out.add({ line: i + 1, code: c, message: c === 'exists' ? '
|
|
707
|
+
out.add({ line: i + 1, code: c, message: lmsg(c === 'exists' ? 'write.load_exists' : 'write.load_deleted'), id: p1[i].id, class: p1[i].cls.name });
|
|
735
708
|
}
|
|
736
709
|
if (out.any)
|
|
737
710
|
out.fail();
|
|
738
|
-
throw
|
|
711
|
+
throw lerr('write.load_objects_exist');
|
|
739
712
|
}
|
|
740
713
|
/** Проверки строки и ссылок; null — строка не пишется (нарушение уже в отчёте). */
|
|
741
714
|
function check(reg, i, inLoad, external, lastVersion) {
|
|
@@ -748,16 +721,16 @@ export async function load(env, input, opts = {}) {
|
|
|
748
721
|
}
|
|
749
722
|
// аккаунты — только в System; членство лежит в арендаторе, в которого вступают (как в триггерах)
|
|
750
723
|
if (cls.name === 'Account' && tenant !== SYSTEM_ID) {
|
|
751
|
-
issues.add({ line: i + 1, code: 'tenant_denied', message: '
|
|
724
|
+
issues.add({ line: i + 1, code: 'tenant_denied', message: lmsg('write.load_account_tenant'), id: p1[i].id, class: cls.name });
|
|
752
725
|
return null;
|
|
753
726
|
}
|
|
754
727
|
if (cls.name === 'member' && sh.links.Tenant !== tenant) {
|
|
755
|
-
issues.add({ line: i + 1, code: 'invalid_data', message: '
|
|
728
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: lmsg('write.load_member_tenant'), id: p1[i].id, class: cls.name });
|
|
756
729
|
return null;
|
|
757
730
|
}
|
|
758
731
|
const tags = raw.$tags;
|
|
759
732
|
if (tags !== undefined && (!Array.isArray(tags) || !tags.every((x) => typeof x === 'string'))) {
|
|
760
|
-
issues.add({ line: i + 1, code: 'invalid_data', message: '
|
|
733
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: lmsg('write.load_tags'), id: p1[i].id, class: cls.name });
|
|
761
734
|
return null;
|
|
762
735
|
}
|
|
763
736
|
if (tags)
|
|
@@ -765,7 +738,7 @@ export async function load(env, input, opts = {}) {
|
|
|
765
738
|
const own = raw.$owner;
|
|
766
739
|
if (own !== undefined && own !== null) {
|
|
767
740
|
if (asAcc || typeof own !== 'string' || !UUID.test(own)) {
|
|
768
|
-
issues.add({ line: i + 1, code: 'invalid_data', message: asAcc ? '
|
|
741
|
+
issues.add({ line: i + 1, code: 'invalid_data', message: lmsg(asAcc ? 'write.load_as_owner' : 'write.load_owner_id'), id: p1[i].id, class: cls.name });
|
|
769
742
|
return null;
|
|
770
743
|
}
|
|
771
744
|
sh.owner = own.toLowerCase();
|
|
@@ -783,7 +756,7 @@ export async function load(env, input, opts = {}) {
|
|
|
783
756
|
const t = inLoad.get(id);
|
|
784
757
|
if (t) {
|
|
785
758
|
if (!t.alive || !accepts(reg, allowed, t.cls))
|
|
786
|
-
issues.add({ line: i + 1, code: 'target_not_found', message:
|
|
759
|
+
issues.add({ line: i + 1, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role }), id: p1[i].id, class: cls.name });
|
|
787
760
|
}
|
|
788
761
|
else if (!external.has(id)) {
|
|
789
762
|
external.set(id, { line: i + 1, role, allowed });
|
|
@@ -792,11 +765,63 @@ export async function load(env, input, opts = {}) {
|
|
|
792
765
|
}
|
|
793
766
|
return sh;
|
|
794
767
|
}
|
|
768
|
+
function leftToDb(reg, cls) {
|
|
769
|
+
return leavesToDb((reg.find(cls.name) ?? cls).validator());
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Правила, которые валидатор на TypeScript оставляет базе (`pattern`: шаблон арендатора процесс приложения не
|
|
773
|
+
* исполняет — ревью, ReDoS), — запросом к базе на пачку строк классов с ними и до записи: COPY идёт мимо триггеров.
|
|
774
|
+
* Нарушения — в отчёт с номером строки, как у проверки в коде.
|
|
775
|
+
*
|
|
776
|
+
* Цена — на каждую строку (в режиме истории — на каждую версию), поэтому (замер против 1.0.1 — засевка полигона была на 42 %
|
|
777
|
+
* медленнее, 40 % времени — здесь): одинаковые класс и данные проверяются один раз, строки классов читаются один
|
|
778
|
+
* раз на пачку, а полный разбор `validate_data` идёт только у строк, которых не пропустил общий `validator` класса.
|
|
779
|
+
* Правил `extra` в `validator` нет, но сюда доходят строки, прошедшие валидатор на TypeScript, — у него те же правила,
|
|
780
|
+
* кроме `pattern` (равенство — тест `validator-parity`); `pattern` в `validator` есть.
|
|
781
|
+
*/
|
|
782
|
+
async function checkInDb(conn, list) {
|
|
783
|
+
// порядок — по первой строке: первые ISSUES_LIMIT нарушений — те же строки, что без схлопывания
|
|
784
|
+
const uniq = new Map();
|
|
785
|
+
for (const r of [...list].sort((a, b) => a.i - b.i)) {
|
|
786
|
+
const key = `${r.class}\u0000${JSON.stringify(r.data)}`;
|
|
787
|
+
const u = uniq.get(key);
|
|
788
|
+
if (u)
|
|
789
|
+
u.lines.push(r.i);
|
|
790
|
+
else
|
|
791
|
+
uniq.set(key, { class: r.class, data: r.data, lines: [r.i] });
|
|
792
|
+
}
|
|
793
|
+
const all = [...uniq.values()];
|
|
794
|
+
const found = [];
|
|
795
|
+
let capped = false;
|
|
796
|
+
for (let b = 0; b < all.length; b += batch) {
|
|
797
|
+
const part = all.slice(b, b + batch);
|
|
798
|
+
// materialized: без него планировщик возвращает class_row в цикл по строкам
|
|
799
|
+
const bad = await conn.unsafe(`with c as materialized (select n as name, r.validator, r.data->'resolved'->'rules' as rules,
|
|
800
|
+
r.data->'resolved'->'vars' as vars from unnest($3::text[]) n cross join lateral ${S}.class_row(n, $2::uuid) r)
|
|
801
|
+
select k, issues from (select (x.ord - 1)::int as k,
|
|
802
|
+
case when jsonb_path_match(x.v->'data', c.validator, c.vars, true) is true then '[]'::jsonb
|
|
803
|
+
else ${S}.validate_data(c.rules, c.validator, c.vars, x.v->'data') end as issues
|
|
804
|
+
from jsonb_array_elements($1::jsonb) with ordinality x(v, ord) join c on c.name = x.v->>'class') s
|
|
805
|
+
where issues <> '[]'::jsonb order by k limit ${ISSUES_LIMIT}`, [part.map((u) => ({ class: u.class, data: u.data })), tenant, [...new Set(part.map((u) => u.class))]]);
|
|
806
|
+
if (bad.length >= ISSUES_LIMIT)
|
|
807
|
+
capped = true;
|
|
808
|
+
for (const r of bad)
|
|
809
|
+
for (const line of part[r.k].lines)
|
|
810
|
+
found.push({ line, raw: r.issues });
|
|
811
|
+
}
|
|
812
|
+
for (const f of found.sort((a, b) => a.line - b.line)) {
|
|
813
|
+
const { id, cls } = p1[f.line];
|
|
814
|
+
// нарушения базы — с ключом каталога: key и params наружу не идут
|
|
815
|
+
issues.add({ line: f.line + 1, code: 'invalid_data', message: lmsg('write.load_invalid_data', { cls: cls.name }), id, class: cls.name, issues: issuesFromDb(f.raw) });
|
|
816
|
+
}
|
|
817
|
+
if (capped)
|
|
818
|
+
issues.unchecked.add(lmsg('write.load_db_check_limit', { limit: ISSUES_LIMIT }));
|
|
819
|
+
}
|
|
795
820
|
/** Пачка — подготовка и запись по очереди. */
|
|
796
|
-
async function writeBatch(conn, reg, part, inLoad, external, ts, xid, touched,
|
|
797
|
-
const p = await prepareBatch(reg, part, inLoad, external, ts, xid, touched,
|
|
821
|
+
async function writeBatch(conn, reg, part, inLoad, external, ts, xid, touched, live, past) {
|
|
822
|
+
const p = await prepareBatch(reg, part, inLoad, external, ts, xid, touched, live, past, 0);
|
|
798
823
|
if (p)
|
|
799
|
-
await flushBatch(conn, p, ts, touched
|
|
824
|
+
await flushBatch(conn, p, ts, touched);
|
|
800
825
|
}
|
|
801
826
|
/**
|
|
802
827
|
* Первая заливка — конвейером (решение точки А плана 1.1): следующая пачка готовится, пока база пишет текущую.
|
|
@@ -804,18 +829,22 @@ export async function load(env, input, opts = {}) {
|
|
|
804
829
|
* идёт синхронная работа. Отказ записи до её await ловит пустой обработчик (иначе — необработанный отказ
|
|
805
830
|
* процесса); при ошибке подготовки запись в полёте доходит до конца, и откат идёт по готовому соединению.
|
|
806
831
|
*/
|
|
807
|
-
async function pipeline(conn, reg, portion, inLoad, external, ts, xid, touched,
|
|
832
|
+
async function pipeline(conn, reg, portion, inLoad, external, ts, xid, touched, live, past) {
|
|
808
833
|
let flushing = null;
|
|
809
834
|
try {
|
|
810
835
|
for (let b = 0; b < portion.length; b += batch) {
|
|
811
|
-
const p = await prepareBatch(reg, portion.slice(b, b + batch), inLoad, external, ts, xid, touched,
|
|
836
|
+
const p = await prepareBatch(reg, portion.slice(b, b + batch), inLoad, external, ts, xid, touched, live, past, 256);
|
|
812
837
|
const prev = flushing;
|
|
813
838
|
flushing = null;
|
|
814
839
|
if (prev)
|
|
815
840
|
await prev;
|
|
816
|
-
if (!p || issues.any)
|
|
841
|
+
if (!p || issues.any) {
|
|
842
|
+
// нарушение нашла проверка базы прошлой пачки, когда эта была уже готова: её строки база не проверила
|
|
843
|
+
if (p?.inDb.length)
|
|
844
|
+
issues.unchecked.add(lmsg('write.load_db_rules_unchecked'));
|
|
817
845
|
return;
|
|
818
|
-
|
|
846
|
+
}
|
|
847
|
+
flushing = flushBatch(conn, p, ts, touched);
|
|
819
848
|
flushing.catch(() => { });
|
|
820
849
|
}
|
|
821
850
|
const last = flushing;
|
|
@@ -833,11 +862,13 @@ export async function load(env, input, opts = {}) {
|
|
|
833
862
|
* Подготовка пачки без запросов к базе: проверки, id, концы, строки COPY. yieldEvery — уступать циклу событий
|
|
834
863
|
* каждые столько строк (конвейер первой заливки). Нарушения — null.
|
|
835
864
|
*/
|
|
836
|
-
async function prepareBatch(reg, part, inLoad, external, ts, xid, touched,
|
|
865
|
+
async function prepareBatch(reg, part, inLoad, external, ts, xid, touched, live, past, yieldEvery) {
|
|
837
866
|
const ent = [];
|
|
838
867
|
const log = [];
|
|
839
868
|
const updates = [];
|
|
840
869
|
const rowsAcl = [];
|
|
870
|
+
const inDb = [];
|
|
871
|
+
let dbLeft = false;
|
|
841
872
|
let n = 0;
|
|
842
873
|
for (const i of part) {
|
|
843
874
|
if (yieldEvery && ++n % yieldEvery === 0)
|
|
@@ -848,14 +879,20 @@ export async function load(env, input, opts = {}) {
|
|
|
848
879
|
continue;
|
|
849
880
|
}
|
|
850
881
|
const sh = check(reg, i, inLoad, external, true);
|
|
882
|
+
if (sh && leftToDb(reg, cls)) {
|
|
883
|
+
if (issues.any)
|
|
884
|
+
dbLeft = true;
|
|
885
|
+
else
|
|
886
|
+
inDb.push({ i, class: cls.name, data: sh.data });
|
|
887
|
+
}
|
|
851
888
|
if (!sh || issues.any)
|
|
852
889
|
continue;
|
|
853
890
|
if (live.has(id)) {
|
|
854
|
-
// строка с тем же id, но другого класса (класс
|
|
891
|
+
// строка с тем же id, но другого класса (класс строки сменили мимо триггеров): правка
|
|
855
892
|
// прошла бы мимо триггеров с данными, проверенными по чужой схеме, — exists (план 2.0, 7.2 №4)
|
|
856
893
|
const was = live.get(id);
|
|
857
894
|
if (was !== cls.name) {
|
|
858
|
-
issues.add({ line: i + 1, code: 'exists', message:
|
|
895
|
+
issues.add({ line: i + 1, code: 'exists', message: lmsg('write.load_other_class', { was, cls: cls.name }), id, class: cls.name });
|
|
859
896
|
continue;
|
|
860
897
|
}
|
|
861
898
|
updates.push({ id, data: sh.data, links: sh.links, tags: sh.tags ?? null, line: i + 1 });
|
|
@@ -873,25 +910,27 @@ export async function load(env, input, opts = {}) {
|
|
|
873
910
|
rowsAcl.push({ i, class: cls.name, id, owner, tags, data: sh.data, links: sh.links });
|
|
874
911
|
// общая голова строк entity и log — одной сборкой и одним экранированием
|
|
875
912
|
const head = [id, rev, cls.name, cur.rev, tenant, owner, JSON.stringify(sh.links), JSON.stringify(sh.data), pgArray(tags), atv].map(esc).join('\t');
|
|
876
|
-
|
|
877
|
-
ent.push(`${head}\t${esc(atv)}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t\\N\t${ph}\t\\N\n`);
|
|
913
|
+
ent.push(`${head}\t${esc(atv)}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t\\N\n`);
|
|
878
914
|
if (cur.history !== false)
|
|
879
|
-
log.push(`${head}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t
|
|
915
|
+
log.push(`${head}\t${esc(author)}\t${esc(agent)}\tcreate\t\\N\t${esc(xid)}\n`);
|
|
880
916
|
report.loaded++;
|
|
881
917
|
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
882
918
|
touched.add(cls.name);
|
|
883
|
-
written.push(id);
|
|
884
919
|
}
|
|
885
920
|
if (issues.any) {
|
|
886
921
|
if (aclRan)
|
|
887
|
-
issues.unchecked.add('
|
|
922
|
+
issues.unchecked.add(lmsg('write.load_later_batches'));
|
|
923
|
+
if (dbLeft || inDb.length)
|
|
924
|
+
issues.unchecked.add(lmsg('write.load_db_rules_unchecked'));
|
|
888
925
|
return null;
|
|
889
926
|
}
|
|
890
|
-
return { ent, log, updates, rowsAcl };
|
|
927
|
+
return { ent, log, updates, rowsAcl, inDb };
|
|
891
928
|
}
|
|
892
929
|
/** Запись подготовленной пачки: условия ACL ({ as }) — запросом на пачку, COPY в entity и log, правки существующих. */
|
|
893
|
-
async function flushBatch(conn, p, ts, touched
|
|
894
|
-
const { ent, log, updates, rowsAcl } = p;
|
|
930
|
+
async function flushBatch(conn, p, ts, touched) {
|
|
931
|
+
const { ent, log, updates, rowsAcl, inDb } = p;
|
|
932
|
+
if (inDb.length)
|
|
933
|
+
await checkInDb(conn, inDb);
|
|
895
934
|
if (acl) {
|
|
896
935
|
aclRan = true;
|
|
897
936
|
// условия ACL на строки — одним запросом на пачку: файл пользователя — не доверенные данные
|
|
@@ -899,7 +938,7 @@ export async function load(env, input, opts = {}) {
|
|
|
899
938
|
where not ${S}.acl_row_ok($2::jsonb, x->>'class', (x->>'id')::uuid, (x->>'owner')::uuid, $3::uuid,
|
|
900
939
|
array(select jsonb_array_elements_text(x->'tags')), x->'data', x->'links')`, [rowsAcl, acl.write, tenant]);
|
|
901
940
|
for (const b of bad)
|
|
902
|
-
issues.add({ line: b.i + 1, code: 'acl_denied', message: '
|
|
941
|
+
issues.add({ line: b.i + 1, code: 'acl_denied', message: lmsg('write.load_acl_row'), id: p1[b.i].id });
|
|
903
942
|
if (updates.length) {
|
|
904
943
|
// existing: 'update' — условие на прежние значения и на новые (как политика правки)
|
|
905
944
|
const ids = updates.map((u) => u.id);
|
|
@@ -910,42 +949,39 @@ export async function load(env, input, opts = {}) {
|
|
|
910
949
|
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]);
|
|
911
950
|
const lineOfId = new Map(updates.map((u) => [u.id, u.line]));
|
|
912
951
|
for (const r of [...old, ...fresh])
|
|
913
|
-
issues.add({ line: lineOfId.get(r.id) ?? 0, code: 'acl_denied', message: '
|
|
952
|
+
issues.add({ line: lineOfId.get(r.id) ?? 0, code: 'acl_denied', message: lmsg('write.load_acl_update'), id: r.id });
|
|
914
953
|
}
|
|
915
954
|
if (issues.any)
|
|
916
955
|
return;
|
|
917
956
|
}
|
|
918
|
-
|
|
919
|
-
|
|
957
|
+
if (issues.any)
|
|
958
|
+
return;
|
|
959
|
+
// первая заливка: FREEZE — таблицы опустошены этой же транзакцией
|
|
920
960
|
await copyLines(conn, `copy ${S}.log (${LOG_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, log);
|
|
921
961
|
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
922
962
|
if (updates.length) {
|
|
923
|
-
// новая версия из заблокированных значений — одним оператором; без изменений — без
|
|
924
|
-
|
|
925
|
-
const res = await conn.unsafe(`with u as (select x.id, ${S}.agg_keep(e.class, e.tenant, e.data, x.data) as data, x.links, x.tags
|
|
926
|
-
from jsonb_to_recordset($1::jsonb) as x(id uuid, data jsonb, links jsonb, tags text[]) join ${S}.entity e on e.id = x.id),
|
|
963
|
+
// новая версия из заблокированных значений — одним оператором; без изменений — без версии
|
|
964
|
+
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[])),
|
|
927
965
|
up as (
|
|
928
|
-
update ${S}.entity e set data = u.data, links = u.links, tags = coalesce(u.tags, e.tags), rev = e.rev + 1,
|
|
966
|
+
update ${S}.entity e set data = u.data, links = u.links, tags = coalesce(u.tags, e.tags), rev = e.rev + 1,
|
|
929
967
|
at = greatest($2::timestamptz, e.at + interval '1 microsecond'), author = $3::uuid, agent = $4::text, op = 'update',
|
|
930
|
-
reason = null,
|
|
968
|
+
reason = null, cv = coalesce((${S}.class_row(e.class, e.tenant)).rev, e.cv)
|
|
931
969
|
from u where e.id = u.id and (e.data is distinct from u.data or e.links is distinct from u.links
|
|
932
970
|
or e.tags is distinct from coalesce(u.tags, e.tags))
|
|
933
971
|
returning e.*),
|
|
934
972
|
lg as (
|
|
935
|
-
insert into ${S}.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason
|
|
936
|
-
select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason
|
|
973
|
+
insert into ${S}.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason)
|
|
974
|
+
select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason from up
|
|
937
975
|
where ${S}.history_on(up.class, up.tenant) order by up.id)
|
|
938
976
|
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]);
|
|
939
977
|
report.updated += res.length;
|
|
940
978
|
report.skipped += updates.length - res.length;
|
|
941
|
-
for (const r of res)
|
|
979
|
+
for (const r of res)
|
|
942
980
|
touched.add(r.class);
|
|
943
|
-
written.push(r.id);
|
|
944
|
-
}
|
|
945
981
|
}
|
|
946
982
|
}
|
|
947
|
-
/** Режим истории: версии
|
|
948
|
-
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched
|
|
983
|
+
/** Режим истории: версии в журнал — волнами по номеру (первые версии всех объектов, затем вторые…), вставками по 5000. */
|
|
984
|
+
async function historyWrite(conn, reg, portion, inLoad, external, xid, touched) {
|
|
949
985
|
const byId = new Map();
|
|
950
986
|
for (const i of portion) {
|
|
951
987
|
const id = p1[i].id;
|
|
@@ -958,7 +994,7 @@ export async function load(env, input, opts = {}) {
|
|
|
958
994
|
for (let k = 0; k < l.length; k++) {
|
|
959
995
|
const r = rows[l[k]];
|
|
960
996
|
if (Number(r.$rev) !== k + 1 || typeof r.$at !== 'string') {
|
|
961
|
-
issues.add({ line: l[k] + 1, code: 'invalid_data', message: '
|
|
997
|
+
issues.add({ line: l[k] + 1, code: 'invalid_data', message: lmsg('write.load_history_versions'), id: p1[l[k]].id });
|
|
962
998
|
}
|
|
963
999
|
}
|
|
964
1000
|
}
|
|
@@ -969,16 +1005,31 @@ export async function load(env, input, opts = {}) {
|
|
|
969
1005
|
if (sh)
|
|
970
1006
|
shaped.set(i, sh);
|
|
971
1007
|
});
|
|
1008
|
+
// правила, которые проверяет только база (pattern), — у всех версий, до записи
|
|
1009
|
+
const inDb = [];
|
|
1010
|
+
for (const [i, sh] of shaped)
|
|
1011
|
+
if (leftToDb(reg, p1[i].cls))
|
|
1012
|
+
inDb.push({ i, class: p1[i].cls.name, data: sh.data });
|
|
1013
|
+
if (issues.any) {
|
|
1014
|
+
if (inDb.length)
|
|
1015
|
+
issues.unchecked.add(lmsg('write.load_db_rules_unchecked'));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
await checkInDb(conn, inDb);
|
|
972
1019
|
if (issues.any)
|
|
973
1020
|
return;
|
|
974
1021
|
// без разворота в аргументы: на сотнях тысяч объектов он переполняет стек
|
|
975
1022
|
const waves = lists.reduce((m, l) => Math.max(m, l.length), 0);
|
|
976
|
-
const
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
1023
|
+
const items = [];
|
|
1024
|
+
const flush = async () => {
|
|
1025
|
+
if (!items.length)
|
|
1026
|
+
return;
|
|
1027
|
+
await conn.unsafe(`insert into ${S}.log (${LOG_COLS})
|
|
1028
|
+
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, x.tx::xid8
|
|
1029
|
+
from jsonb_to_recordset($1::jsonb) as x(id uuid, rev int, class text, cv int, tenant uuid, owner uuid, tags text[], links jsonb,
|
|
1030
|
+
data jsonb, at timestamptz, author uuid, agent text, op text, tx text)`, [items.splice(0)]);
|
|
1031
|
+
};
|
|
980
1032
|
for (let w = 0; w < waves; w++) {
|
|
981
|
-
const items = [];
|
|
982
1033
|
for (const l of lists) {
|
|
983
1034
|
const i = l[w];
|
|
984
1035
|
if (i === undefined)
|
|
@@ -990,25 +1041,14 @@ export async function load(env, input, opts = {}) {
|
|
|
990
1041
|
const r = rows[i];
|
|
991
1042
|
const sh = shaped.get(i);
|
|
992
1043
|
const op = typeof r.$op === 'string' ? r.$op : w === 0 ? 'create' : 'update';
|
|
993
|
-
const ph = prevHash.get(id) ?? null;
|
|
994
|
-
if (w === l.length - 1)
|
|
995
|
-
lastPrev.set(id, ph);
|
|
996
1044
|
items.push({ id, rev: w + 1, class: cls.name, cv: cur.rev, tenant, owner: sh.owner ?? tenant, tags: sh.tags ?? [], links: sh.links,
|
|
997
|
-
data: sh.data, at: r.$at, author: SYSTEM_ID, agent, op,
|
|
1045
|
+
data: sh.data, at: r.$at, author: SYSTEM_ID, agent, op, tx: xid });
|
|
998
1046
|
touched.add(cls.name);
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const res = await conn.unsafe(`insert into ${S}.log (${LOG_COLS})
|
|
1002
|
-
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,
|
|
1003
|
-
decode(x.ph, 'hex'), x.tx::xid8
|
|
1004
|
-
from jsonb_to_recordset($1::jsonb) as x(id uuid, rev int, class text, cv int, tenant uuid, owner uuid, tags text[], links jsonb,
|
|
1005
|
-
data jsonb, at timestamptz, author uuid, agent text, op text, ph text, tx text)
|
|
1006
|
-
returning id, hash`, [items.slice(k, k + 5000)]);
|
|
1007
|
-
if (w < waves - 1)
|
|
1008
|
-
for (const h of res)
|
|
1009
|
-
prevHash.set(h.id, h.hash);
|
|
1047
|
+
if (items.length >= 5000)
|
|
1048
|
+
await flush();
|
|
1010
1049
|
}
|
|
1011
1050
|
}
|
|
1051
|
+
await flush();
|
|
1012
1052
|
// текущее состояние — последняя версия, если она не надгробие; created_at — время первой версии
|
|
1013
1053
|
const ent = [];
|
|
1014
1054
|
for (const l of lists) {
|
|
@@ -1021,11 +1061,9 @@ export async function load(env, input, opts = {}) {
|
|
|
1021
1061
|
const sh = shaped.get(last);
|
|
1022
1062
|
const op = typeof r.$op === 'string' ? r.$op : l.length === 1 ? 'create' : 'update';
|
|
1023
1063
|
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,
|
|
1024
|
-
rows[l[0]].$at, SYSTEM_ID, agent, op, null, null
|
|
1064
|
+
rows[l[0]].$at, SYSTEM_ID, agent, op, null, null].map(esc).join('\t') + '\n');
|
|
1025
1065
|
report.loaded++;
|
|
1026
1066
|
report.byClass[cls.name] = (report.byClass[cls.name] ?? 0) + 1;
|
|
1027
|
-
// verify: true проверяет и строки режима истории (в 1.0.1 список был пуст — проверка не шла)
|
|
1028
|
-
written.push(id);
|
|
1029
1067
|
}
|
|
1030
1068
|
await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
|
|
1031
1069
|
}
|
|
@@ -1061,7 +1099,7 @@ async function catalogOf(conn, S) {
|
|
|
1061
1099
|
}
|
|
1062
1100
|
/**
|
|
1063
1101
|
* Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
|
|
1064
|
-
* проверки; в схеме только строки системных классов
|
|
1102
|
+
* проверки; в схеме только строки системных классов (иначе not_empty); они (с seq и tx
|
|
1065
1103
|
* журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
|
|
1066
1104
|
* pg_get_triggerdef его не передаёт) сохраняются в памяти; внешний ключ secret.account снимается до TRUNCATE и
|
|
1067
1105
|
* возвращается после первичного ключа entity; TRUNCATE; триггеры, ограничения и индексы
|
|
@@ -1073,7 +1111,7 @@ async function catalogOf(conn, S) {
|
|
|
1073
1111
|
*/
|
|
1074
1112
|
export async function initialSession(env, fn, tune = {}) {
|
|
1075
1113
|
if (!env.admin)
|
|
1076
|
-
throw
|
|
1114
|
+
throw lerr('write.load_admin_required');
|
|
1077
1115
|
const S = `"${env.schema.replace(/"/g, '""')}"`;
|
|
1078
1116
|
const ident = (n) => `"${n.replace(/"/g, '""')}"`;
|
|
1079
1117
|
const conn = await env.sql.reserve();
|
|
@@ -1085,11 +1123,11 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1085
1123
|
await q(`select set_config('TimeZone', 'UTC', true)`);
|
|
1086
1124
|
// 1. блокировка — до проверки: строка, вставленная между проверкой и TRUNCATE, пропала бы молча
|
|
1087
1125
|
await q(`lock table ${S}.entity, ${S}.log in access exclusive mode`);
|
|
1088
|
-
// 2. пустая схема: кроме строк системных классов
|
|
1126
|
+
// 2. пустая схема: кроме строк системных классов — ничего
|
|
1089
1127
|
const [busy] = await q(`select exists (select 1 from ${S}.entity where class <> all ($1::text[]))
|
|
1090
|
-
or exists (select 1 from ${S}.log where class <> all ($
|
|
1128
|
+
or exists (select 1 from ${S}.log where class <> all ($1::text[])) as b`, [SYSTEM_CLASSES]);
|
|
1091
1129
|
if (busy.b) {
|
|
1092
|
-
throw
|
|
1130
|
+
throw lerr('write.load_not_empty');
|
|
1093
1131
|
}
|
|
1094
1132
|
// 3. системные строки и определения из каталога — в память процесса (так режим не повторяет 10-core.sql)
|
|
1095
1133
|
const [sys] = await q(`select coalesce((select jsonb_agg(to_jsonb(e)) from ${S}.entity e), '[]') as ent,
|
|
@@ -1115,15 +1153,8 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1115
1153
|
await q(`insert into ${S}.entity (${cat.entityCols}) select ${cat.entityCols} from jsonb_populate_recordset(null::${S}.entity, $1::jsonb)`, [sys.ent]);
|
|
1116
1154
|
await q(`insert into ${S}.log (${cat.logCols}) overriding system value select ${cat.logCols} from jsonb_populate_recordset(null::${S}.log, $1::jsonb)`, [sys.log]);
|
|
1117
1155
|
const state = { conn: conn, ids, pending: [], touched: new Map(), freeze: tune.freeze !== false };
|
|
1118
|
-
const out = await fn({
|
|
1119
|
-
|
|
1120
|
-
load: (input, opts = {}) => load({ ...env, session: state }, input, opts),
|
|
1121
|
-
taken: (id) => state.ids.has(id.toLowerCase()),
|
|
1122
|
-
});
|
|
1123
|
-
// 6. агрегаты в базе (план 1.1, §2.4): триггеров нет — поле целей считает agg_fill, одним оператором на агрегат, и
|
|
1124
|
-
// пишет его в ту же (последнюю) версию в entity и log: строки ещё никто не видел, хэши — генерируемые колонки
|
|
1125
|
-
await q(`select ${S}.agg_fill()`);
|
|
1126
|
-
// 7. ограничения, затем индексы; память и параллельные исполнители сборки — не меньше серверных
|
|
1156
|
+
const out = await fn({ load: (input, opts = {}) => load({ ...env, session: state }, input, opts) });
|
|
1157
|
+
// 6. ограничения, затем индексы; память и параллельные исполнители сборки — не меньше серверных
|
|
1127
1158
|
await q(`select set_config('maintenance_work_mem', (greatest(pg_size_bytes(current_setting('maintenance_work_mem')), 268435456) / 1024)::bigint || 'kB', true),
|
|
1128
1159
|
set_config('max_parallel_maintenance_workers', greatest(current_setting('max_parallel_maintenance_workers')::int,
|
|
1129
1160
|
least(current_setting('max_parallel_workers')::int, 4))::text, true)`);
|
|
@@ -1135,14 +1166,14 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1135
1166
|
await q(`alter table ${f.tbl} add constraint ${ident(f.name)} ${f.def}`);
|
|
1136
1167
|
for (const i of indexes)
|
|
1137
1168
|
await q(i.def);
|
|
1138
|
-
//
|
|
1169
|
+
// 7. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
|
|
1139
1170
|
for (const t of triggers) {
|
|
1140
1171
|
await q(t.def);
|
|
1141
1172
|
const mode = t.enabled === 'A' ? 'enable always' : t.enabled === 'R' ? 'enable replica' : t.enabled === 'D' ? 'disable' : null;
|
|
1142
1173
|
if (mode)
|
|
1143
1174
|
await q(`alter table ${t.tbl} ${mode} trigger ${ident(t.name)}`);
|
|
1144
1175
|
}
|
|
1145
|
-
//
|
|
1176
|
+
// 8. отложенные проверки загрузок, сигнал, счётчик прав
|
|
1146
1177
|
const issues = new Issues();
|
|
1147
1178
|
for (const p of state.pending) {
|
|
1148
1179
|
const ext = [...p.external.keys()].sort();
|
|
@@ -1153,10 +1184,7 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1153
1184
|
const ref = p.external.get(id);
|
|
1154
1185
|
const f = found.get(id);
|
|
1155
1186
|
if (!f || (f.tenant !== p.tenant && f.tenant !== SYSTEM_ID) || !accepts(p.reg, ref.allowed, f.class)) {
|
|
1156
|
-
issues.add({ line: ref.line, code: 'target_not_found', message:
|
|
1157
|
-
}
|
|
1158
|
-
else if (p.reg.find(f.class)?.derivedBy) {
|
|
1159
|
-
issues.add({ line: ref.line, code: 'derived_class', message: `цель конца ${ref.role} — строка производного класса ${f.class}`, id });
|
|
1187
|
+
issues.add({ line: ref.line, code: 'target_not_found', message: lmsg('write.load_target_not_found', { role: ref.role }), id });
|
|
1160
1188
|
}
|
|
1161
1189
|
}
|
|
1162
1190
|
}
|
|
@@ -1165,13 +1193,11 @@ export async function initialSession(env, fn, tune = {}) {
|
|
|
1165
1193
|
const found = new Set((await q(`select e.id from ${S}.entity e where e.id = any ($1::uuid[]) and e.class = 'Account'`, [own])).map((r) => r.id));
|
|
1166
1194
|
for (const id of own)
|
|
1167
1195
|
if (!found.has(id))
|
|
1168
|
-
issues.add({ line: p.owners.get(id), code: 'target_not_found', message:
|
|
1196
|
+
issues.add({ line: p.owners.get(id), code: 'target_not_found', message: lmsg('write.load_owner_not_found', { id }), id });
|
|
1169
1197
|
}
|
|
1170
1198
|
const [acc] = await q(`select coalesce((data->>'enabled')::boolean, true) as enabled from ${S}.entity where id = $1::uuid and class = 'Account'`, [p.tenant]);
|
|
1171
1199
|
if (!acc || !acc.enabled)
|
|
1172
|
-
issues.add({ line: 0, code: 'tenant_denied', message:
|
|
1173
|
-
if (p.verify?.length)
|
|
1174
|
-
await verifyIds(conn, S, p.verify, issues);
|
|
1200
|
+
issues.add({ line: 0, code: 'tenant_denied', message: lmsg('write.load_tenant_denied', { tenant: p.tenant }) });
|
|
1175
1201
|
}
|
|
1176
1202
|
if (issues.any)
|
|
1177
1203
|
issues.fail();
|