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/import.js
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Импорт из letopis 0.21 (план, этап 7, п. 1): `letopis import --from <строка 0.21> --from-schema v1.x`.
|
|
3
|
+
*
|
|
4
|
+
* Читает таблицы 0.21 (Schema, Entity, Account, Credential, Resource, Rule) из её собственной базы —
|
|
5
|
+
* PostgreSQL 17 с TimescaleDB или обычный дамп таблиц — и пишет в установку 1.0:
|
|
6
|
+
* - Schema → строки Class арендатора System (их видят все арендаторы): правила fastest-validator →
|
|
7
|
+
* JSON Schema, концы — роли по имени класса цели, союз всех конкретных потомков класса —
|
|
8
|
+
* полиморфный конец этого класса; корни без своих полей и концов (Entity, link) опускаются;
|
|
9
|
+
* description, appearance, order → meta; ссылка на класс в данных, не объявленная концом, —
|
|
10
|
+
* необязательный конец (в отчёте);
|
|
11
|
+
* - Entity → загрузчиком в режиме истории (existing: 'skip', агент «letopis import»): версии по
|
|
12
|
+
* порядку, надгробия — op delete, следующая за ним версия — create; id классов с ключом — v5 от
|
|
13
|
+
* ключа (§2.3), без ключа — v5 от старого id ($ext); данные System — в арендатора --tenant;
|
|
14
|
+
* - Account → строки Account (id — v5 от старого; System → System), Credential → secret (хэши scrypt
|
|
15
|
+
* и ключей как есть, OTP — нет), Resource и Rule → строки Resource и rule в System.
|
|
16
|
+
* До первой записи импорт проверяет всё, что его остановит: два объекта с одним id 1.0 (нарушение
|
|
17
|
+
* ключа — опция keyless), ключ, менявшийся между версиями, ключ со ссылкой на отсутствующий объект.
|
|
18
|
+
* Висячие ссылки живых строк останавливает загрузчик (target_not_found) — отчёт называет строки 0.21.
|
|
19
|
+
* Шаги (классы, аккаунты, креды, права, данные арендатора) — отдельные транзакции; повтор дописывает
|
|
20
|
+
* недостающее и дублей не создаёт.
|
|
21
|
+
*/
|
|
22
|
+
import postgres from 'postgres';
|
|
23
|
+
import { connect } from './index.js';
|
|
24
|
+
import { LetopisError } from './errors.js';
|
|
25
|
+
import { normalizeSchema } from './jsonschema.js';
|
|
26
|
+
import { RESERVED_CLASS_NAMES } from './types.js';
|
|
27
|
+
import { base32Decode } from './auth.js';
|
|
28
|
+
import { v5Id } from './uuid.js';
|
|
29
|
+
const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
30
|
+
const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
|
|
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
|
+
// fastest-validator → JSON Schema
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
36
|
+
/** Значение из краткой записи «number|min:0|default:1». */
|
|
37
|
+
function shortVal(v) {
|
|
38
|
+
if (v === 'true')
|
|
39
|
+
return true;
|
|
40
|
+
if (v === 'false')
|
|
41
|
+
return false;
|
|
42
|
+
if (v !== '' && !Number.isNaN(Number(v)))
|
|
43
|
+
return Number(v);
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
/** Краткая запись правила → объект правила. */
|
|
47
|
+
function parseShort(s) {
|
|
48
|
+
const [type, ...flags] = s.split('|').map((x) => x.trim());
|
|
49
|
+
const r = { type };
|
|
50
|
+
for (const f of flags) {
|
|
51
|
+
const at = f.indexOf(':');
|
|
52
|
+
if (at < 0)
|
|
53
|
+
r[f] = true;
|
|
54
|
+
else
|
|
55
|
+
r[f.slice(0, at)] = shortVal(f.slice(at + 1));
|
|
56
|
+
}
|
|
57
|
+
return r;
|
|
58
|
+
}
|
|
59
|
+
/** Ключи правил, которые переводятся (остальные — в отчёт). */
|
|
60
|
+
const HANDLED = new Set(['type', '$$type', 'optional', 'nullable', 'default', 'convert', 'strict', 'props', 'properties', 'items', 'key',
|
|
61
|
+
'value', 'values', 'min', 'max', 'length', 'integer', 'positive', 'negative', 'empty', 'pattern', 'unique', 'rules', 'messages', 'label']);
|
|
62
|
+
/** Разрешить null у необязательного поля (fastest-validator пропускает null у optional). */
|
|
63
|
+
function allowNull(s) {
|
|
64
|
+
const keys = Object.keys(s).filter((k) => k !== 'default');
|
|
65
|
+
if (!keys.length)
|
|
66
|
+
return s;
|
|
67
|
+
if (typeof s.type === 'string' && keys.length === 1)
|
|
68
|
+
return { ...s, type: [s.type, 'null'] };
|
|
69
|
+
if (Array.isArray(s.enum) && keys.length === 1)
|
|
70
|
+
return { ...s, enum: [...s.enum, null] };
|
|
71
|
+
const { default: d, ...rest } = s;
|
|
72
|
+
return { anyOf: [rest, { type: 'null' }], ...(d !== undefined ? { default: d } : {}) };
|
|
73
|
+
}
|
|
74
|
+
function conv(rule, cls, path, notes) {
|
|
75
|
+
const note = (n) => notes.push({ class: cls, field: path, note: n });
|
|
76
|
+
if (Array.isArray(rule)) {
|
|
77
|
+
const parts = rule.map((r) => conv(r, cls, path, notes));
|
|
78
|
+
return { s: { anyOf: parts.map((p) => p.s) }, optional: parts.some((p) => p.optional), dflt: false };
|
|
79
|
+
}
|
|
80
|
+
const r = typeof rule === 'string' ? parseShort(rule) : isObj(rule) ? rule : null;
|
|
81
|
+
if (!r) {
|
|
82
|
+
note(`правило ${JSON.stringify(rule)} не разобрано — поле без проверки`);
|
|
83
|
+
return { s: {}, optional: true, dflt: false };
|
|
84
|
+
}
|
|
85
|
+
const type = String(r.type ?? r.$$type ?? (r.props ? 'object' : 'any'));
|
|
86
|
+
let s;
|
|
87
|
+
const num = (k) => (typeof r[k] === 'number' ? r[k] : undefined);
|
|
88
|
+
switch (type) {
|
|
89
|
+
case 'string': {
|
|
90
|
+
s = { type: 'string' };
|
|
91
|
+
if (num('min') !== undefined)
|
|
92
|
+
s.minLength = num('min');
|
|
93
|
+
if (num('max') !== undefined)
|
|
94
|
+
s.maxLength = num('max');
|
|
95
|
+
if (num('length') !== undefined)
|
|
96
|
+
s.minLength = s.maxLength = num('length');
|
|
97
|
+
if (r.empty === false)
|
|
98
|
+
s.minLength = Math.max(1, s.minLength ?? 0);
|
|
99
|
+
if (typeof r.pattern === 'string')
|
|
100
|
+
s.pattern = r.pattern;
|
|
101
|
+
for (const k of ['trim', 'lowercase', 'uppercase', 'padStart', 'padEnd', 'alpha', 'numeric', 'alphanum', 'alphadash', 'hex', 'singleLine', 'base64', 'contains', 'enum']) {
|
|
102
|
+
if (k in r)
|
|
103
|
+
note(`string.${k} не переносится`);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'number': {
|
|
108
|
+
s = { type: r.integer === true ? 'integer' : 'number' };
|
|
109
|
+
if (num('min') !== undefined)
|
|
110
|
+
s.minimum = num('min');
|
|
111
|
+
if (num('max') !== undefined)
|
|
112
|
+
s.maximum = num('max');
|
|
113
|
+
if (r.positive === true)
|
|
114
|
+
s.exclusiveMinimum = 0;
|
|
115
|
+
if (r.negative === true)
|
|
116
|
+
s.exclusiveMaximum = 0;
|
|
117
|
+
for (const k of ['equal', 'notEqual'])
|
|
118
|
+
if (k in r)
|
|
119
|
+
note(`number.${k} не переносится`);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'boolean':
|
|
123
|
+
s = { type: 'boolean' };
|
|
124
|
+
break;
|
|
125
|
+
case 'date':
|
|
126
|
+
s = { type: 'string', format: 'date-time' };
|
|
127
|
+
break;
|
|
128
|
+
case 'uuid':
|
|
129
|
+
s = { type: 'string', format: 'uuid' };
|
|
130
|
+
if ('version' in r)
|
|
131
|
+
note('uuid.version не переносится');
|
|
132
|
+
break;
|
|
133
|
+
case 'email':
|
|
134
|
+
s = { type: 'string', format: 'email' };
|
|
135
|
+
break;
|
|
136
|
+
case 'url':
|
|
137
|
+
s = { type: 'string' };
|
|
138
|
+
note('url → строка без проверки формата');
|
|
139
|
+
break;
|
|
140
|
+
case 'enum':
|
|
141
|
+
s = { enum: Array.isArray(r.values) ? r.values : [] };
|
|
142
|
+
break;
|
|
143
|
+
case 'equal':
|
|
144
|
+
s = { const: r.value };
|
|
145
|
+
break;
|
|
146
|
+
case 'any':
|
|
147
|
+
s = {};
|
|
148
|
+
break;
|
|
149
|
+
case 'object': {
|
|
150
|
+
const props = (r.props ?? r.properties);
|
|
151
|
+
s = { type: 'object' };
|
|
152
|
+
if (isObj(props)) {
|
|
153
|
+
const properties = {};
|
|
154
|
+
const required = [];
|
|
155
|
+
let strict = r.strict === true || r.strict === 'remove';
|
|
156
|
+
for (const [k, v] of Object.entries(props)) {
|
|
157
|
+
if (k === '$$strict') {
|
|
158
|
+
strict = v === true || v === 'remove';
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (k.startsWith('$$')) {
|
|
162
|
+
note(`директива ${k} не переносится`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const c = conv(v, cls, `${path}.${k}`, notes);
|
|
166
|
+
properties[k] = c.s;
|
|
167
|
+
if (!c.optional && !c.dflt)
|
|
168
|
+
required.push(k);
|
|
169
|
+
}
|
|
170
|
+
s.properties = properties;
|
|
171
|
+
if (required.length)
|
|
172
|
+
s.required = required;
|
|
173
|
+
if (strict)
|
|
174
|
+
s.additionalProperties = false;
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'record':
|
|
179
|
+
s = { type: 'object' };
|
|
180
|
+
if (r.key !== undefined)
|
|
181
|
+
s.propertyNames = conv(r.key, cls, `${path}.<ключ>`, notes).s;
|
|
182
|
+
if (r.value !== undefined)
|
|
183
|
+
s.additionalProperties = conv(r.value, cls, `${path}.<значение>`, notes).s;
|
|
184
|
+
break;
|
|
185
|
+
case 'array':
|
|
186
|
+
s = { type: 'array' };
|
|
187
|
+
if (r.items !== undefined)
|
|
188
|
+
s.items = conv(r.items, cls, `${path}[]`, notes).s;
|
|
189
|
+
if (num('min') !== undefined)
|
|
190
|
+
s.minItems = num('min');
|
|
191
|
+
if (num('max') !== undefined)
|
|
192
|
+
s.maxItems = num('max');
|
|
193
|
+
if (num('length') !== undefined)
|
|
194
|
+
s.minItems = s.maxItems = num('length');
|
|
195
|
+
if (r.empty === false)
|
|
196
|
+
s.minItems = Math.max(1, s.minItems ?? 0);
|
|
197
|
+
if (r.unique === true)
|
|
198
|
+
s.uniqueItems = true;
|
|
199
|
+
break;
|
|
200
|
+
case 'multi':
|
|
201
|
+
s = { anyOf: Array.isArray(r.rules) ? r.rules.map((x) => conv(x, cls, path, notes).s) : [] };
|
|
202
|
+
break;
|
|
203
|
+
default:
|
|
204
|
+
note(`тип ${type} не поддерживается — поле без проверки`);
|
|
205
|
+
s = {};
|
|
206
|
+
}
|
|
207
|
+
for (const k of Object.keys(r)) {
|
|
208
|
+
if (!HANDLED.has(k) && !['trim', 'lowercase', 'uppercase', 'padStart', 'padEnd', 'alpha', 'numeric', 'alphanum', 'alphadash', 'hex',
|
|
209
|
+
'singleLine', 'base64', 'contains', 'enum', 'equal', 'notEqual', 'version'].includes(k))
|
|
210
|
+
note(`${type}.${k} не переносится`);
|
|
211
|
+
}
|
|
212
|
+
const dflt = 'default' in r;
|
|
213
|
+
if (dflt)
|
|
214
|
+
s.default = r.default;
|
|
215
|
+
const optional = r.optional === true || r.nullable === true;
|
|
216
|
+
if (optional)
|
|
217
|
+
s = allowNull(s);
|
|
218
|
+
return { s, optional, dflt };
|
|
219
|
+
}
|
|
220
|
+
function parseEnd(e) {
|
|
221
|
+
let v = e;
|
|
222
|
+
if (typeof v === 'string') {
|
|
223
|
+
try {
|
|
224
|
+
v = v.trim().startsWith('{') ? JSON.parse(v) : { class: v };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!isObj(v))
|
|
231
|
+
return null;
|
|
232
|
+
const classes = Array.isArray(v.classes) ? v.classes : typeof v.class === 'string' ? [v.class] : [];
|
|
233
|
+
return classes.length ? { classes, optional: v.optional === true } : null;
|
|
234
|
+
}
|
|
235
|
+
function buildClasses(rows, observed, opts) {
|
|
236
|
+
const notes = [];
|
|
237
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
238
|
+
const reserved = new Set([...RESERVED_CLASS_NAMES, ...SYSTEM_CLASSES]);
|
|
239
|
+
const renamed = {};
|
|
240
|
+
const nameOf = (old) => {
|
|
241
|
+
if (opts.rename?.[old])
|
|
242
|
+
return opts.rename[old];
|
|
243
|
+
if (reserved.has(old))
|
|
244
|
+
return `${old}_`;
|
|
245
|
+
return old;
|
|
246
|
+
};
|
|
247
|
+
for (const r of rows)
|
|
248
|
+
if (nameOf(r.id) !== r.id)
|
|
249
|
+
renamed[r.id] = nameOf(r.id);
|
|
250
|
+
// корни без своих полей (кроме id) и концов — Entity и link 0.21 — опускаются (§2.10)
|
|
251
|
+
const omittedSet = new Set(rows.filter((r) => !r.ancestor && Object.keys(r.attributes ?? {}).every((k) => k === 'id' || k === '$$strict')
|
|
252
|
+
&& !(r.links ?? []).length).map((r) => r.id));
|
|
253
|
+
const parentOf = (r) => (r.ancestor && !omittedSet.has(r.ancestor) ? r.ancestor : null);
|
|
254
|
+
const abstract = (id) => byId.get(id)?.meta?.abstract === true;
|
|
255
|
+
const family = (old) => rows.filter((r) => (r.ancestors ?? [r.id]).includes(old) && !abstract(r.id)).map((r) => r.id);
|
|
256
|
+
const keyless = new Set(opts.keyless ?? []);
|
|
257
|
+
for (const k of keyless)
|
|
258
|
+
if (!byId.has(k))
|
|
259
|
+
throw new LetopisError('invalid_data', `import: --keyless ${k} — такого класса в 0.21 нет`);
|
|
260
|
+
const classes = new Map();
|
|
261
|
+
for (const r of rows) {
|
|
262
|
+
if (omittedSet.has(r.id))
|
|
263
|
+
continue;
|
|
264
|
+
const name = nameOf(r.id);
|
|
265
|
+
const attrs = { ...(r.attributes ?? {}) };
|
|
266
|
+
const properties = {};
|
|
267
|
+
const required = [];
|
|
268
|
+
const dropped = [];
|
|
269
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
270
|
+
if (k === 'id' || k === '$$strict')
|
|
271
|
+
continue;
|
|
272
|
+
if (k.startsWith('$')) {
|
|
273
|
+
dropped.push(k);
|
|
274
|
+
notes.push({ class: r.id, field: k, note: 'поле на $ не переносится (метасхема 1.0), значения в данных отброшены' });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const c = conv(v, r.id, k, notes);
|
|
278
|
+
let s = c.s;
|
|
279
|
+
try {
|
|
280
|
+
s = normalizeSchema(c.s, { where: `${r.id}.${k}` });
|
|
281
|
+
}
|
|
282
|
+
catch (e) {
|
|
283
|
+
notes.push({ class: r.id, field: k, note: `${e.message} — поле без проверки` });
|
|
284
|
+
s = {};
|
|
285
|
+
}
|
|
286
|
+
properties[k] = s;
|
|
287
|
+
if (!c.optional && !c.dflt)
|
|
288
|
+
required.push(k);
|
|
289
|
+
}
|
|
290
|
+
// концы: роль — имя класса цели; союз всех конкретных потомков класса — полиморфный конец
|
|
291
|
+
const ends = {};
|
|
292
|
+
const roleOf = new Map();
|
|
293
|
+
const own = (r.links ?? []).map(parseEnd);
|
|
294
|
+
own.forEach((e, i) => {
|
|
295
|
+
if (!e) {
|
|
296
|
+
notes.push({ class: r.id, note: `конец ${JSON.stringify(r.links[i])} не разобран` });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (e.classes.some((c) => omittedSet.has(c))) {
|
|
300
|
+
notes.push({ class: r.id, note: `полиморфный конец на корень ${e.classes.join('|')} не переносится` });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
let role;
|
|
304
|
+
let target;
|
|
305
|
+
if (e.classes.length === 1) {
|
|
306
|
+
role = nameOf(e.classes[0]);
|
|
307
|
+
target = role;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const want = [...new Set(e.classes.flatMap(family))].sort().join(',');
|
|
311
|
+
const anc = rows.find((x) => !omittedSet.has(x.id) && family(x.id).sort().join(',') === want && e.classes.every((c) => (byId.get(c)?.ancestors ?? []).includes(x.id)));
|
|
312
|
+
role = anc ? nameOf(anc.id) : e.classes.map(nameOf).join('_');
|
|
313
|
+
target = anc ? nameOf(anc.id) : e.classes.map(nameOf);
|
|
314
|
+
}
|
|
315
|
+
if (ends[role])
|
|
316
|
+
notes.push({ class: r.id, note: `два конца с ролью ${role}` });
|
|
317
|
+
ends[role] = { class: target, ...(e.optional ? {} : { required: true }), onDelete: 'cascade' };
|
|
318
|
+
for (const c of e.classes)
|
|
319
|
+
for (const f of family(c))
|
|
320
|
+
roleOf.set(f, role);
|
|
321
|
+
for (const c of e.classes)
|
|
322
|
+
roleOf.set(c, role);
|
|
323
|
+
});
|
|
324
|
+
// ссылки в данных на необъявленный класс — необязательный конец (хаб 0.21 их допускал)
|
|
325
|
+
for (const k of observed.get(r.id) ?? []) {
|
|
326
|
+
if (roleOf.has(k))
|
|
327
|
+
continue;
|
|
328
|
+
const role = nameOf(k);
|
|
329
|
+
ends[role] = { class: role, onDelete: 'cascade' };
|
|
330
|
+
roleOf.set(k, role);
|
|
331
|
+
notes.push({ class: r.id, field: role, note: `ссылка на ${k} в данных не объявлена концом — добавлен необязательный конец` });
|
|
332
|
+
}
|
|
333
|
+
// ключ: собственное правило id (v5 — from; v4, v7, any — без ключа)
|
|
334
|
+
let ownKey;
|
|
335
|
+
const idRule = (r.attributes ?? {}).id;
|
|
336
|
+
if (idRule !== undefined) {
|
|
337
|
+
const rule = isObj(idRule) ? idRule : {};
|
|
338
|
+
if (rule.generate === 5 && Array.isArray(rule.from)) {
|
|
339
|
+
ownKey = rule.from.map((f) => {
|
|
340
|
+
const cl = f.split('|');
|
|
341
|
+
const e = own.find((x) => x && x.classes.length === cl.length && cl.every((c) => x.classes.includes(c)));
|
|
342
|
+
return e ? { end: roleOf.get(e.classes[0]), classes: e.classes } : { field: f };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
ownKey = [];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (keyless.has(r.id))
|
|
350
|
+
ownKey = [];
|
|
351
|
+
const meta = {};
|
|
352
|
+
if (r.meta?.description !== undefined)
|
|
353
|
+
meta.description = r.meta.description;
|
|
354
|
+
if (r.meta?.appearance !== undefined)
|
|
355
|
+
meta.appearance = r.meta.appearance;
|
|
356
|
+
if (r.order)
|
|
357
|
+
meta.order = r.order;
|
|
358
|
+
const parent = parentOf(r);
|
|
359
|
+
const desc = {
|
|
360
|
+
name, kind: r.category === 'LINK' ? 'link' : 'hub',
|
|
361
|
+
...(parent ? { parent: nameOf(parent) } : {}),
|
|
362
|
+
...(r.meta?.abstract === true ? { abstract: true } : {}),
|
|
363
|
+
...(r.alias && r.alias !== r.id ? { alias: r.alias } : {}),
|
|
364
|
+
fields: { properties, ...(required.length ? { required } : {}) },
|
|
365
|
+
...(Object.keys(ends).length ? { ends } : {}),
|
|
366
|
+
...(r.meta?.history === false ? { history: false } : {}),
|
|
367
|
+
...(Object.keys(meta).length ? { meta } : {}),
|
|
368
|
+
};
|
|
369
|
+
classes.set(r.id, { old: r.id, name, desc, roleOf, ownKey, dropped, history: r.meta?.history !== false });
|
|
370
|
+
}
|
|
371
|
+
// эффективный ключ: собственный или ближайшего предка; поле ключа — обязательная строка
|
|
372
|
+
const keyMemo = new Map();
|
|
373
|
+
const keyOf = (old) => {
|
|
374
|
+
if (keyMemo.has(old))
|
|
375
|
+
return keyMemo.get(old);
|
|
376
|
+
const r = byId.get(old);
|
|
377
|
+
let k = [];
|
|
378
|
+
for (const a of r.ancestors?.length ? r.ancestors : [old]) {
|
|
379
|
+
const c = classes.get(a);
|
|
380
|
+
if (c?.ownKey) {
|
|
381
|
+
k = c.ownKey;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
if (!c)
|
|
385
|
+
break; // опущенный корень: его правило — v7, без ключа
|
|
386
|
+
}
|
|
387
|
+
keyMemo.set(old, k);
|
|
388
|
+
return k;
|
|
389
|
+
};
|
|
390
|
+
const forced = [];
|
|
391
|
+
for (const c of classes.values()) {
|
|
392
|
+
if (!c.ownKey?.length)
|
|
393
|
+
continue;
|
|
394
|
+
const props = c.desc.fields.properties;
|
|
395
|
+
const bad = c.ownKey.find((p) => 'field' in p && !(props[p.field] && (props[p.field].type === 'string' || (Array.isArray(props[p.field].type)
|
|
396
|
+
&& props[p.field].type.includes('string')))));
|
|
397
|
+
if (bad) {
|
|
398
|
+
notes.push({ class: c.old, field: bad.field, note: 'поле ключа — не строка: класс импортирован без ключа' });
|
|
399
|
+
c.ownKey = [];
|
|
400
|
+
forced.push(c.old);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const c of classes.values()) {
|
|
404
|
+
if (c.ownKey === undefined)
|
|
405
|
+
continue;
|
|
406
|
+
c.desc.key = c.ownKey.map((p) => ('end' in p ? p.end : p.field));
|
|
407
|
+
const fields = c.desc.fields;
|
|
408
|
+
for (const p of c.ownKey) {
|
|
409
|
+
if (!('field' in p))
|
|
410
|
+
continue;
|
|
411
|
+
const s = fields.properties[p.field];
|
|
412
|
+
// поле ключа — обязательная строка без null (у optional с default в 0.21 значение было всегда)
|
|
413
|
+
if (Array.isArray(s.type))
|
|
414
|
+
fields.properties[p.field] = { ...s, type: 'string' };
|
|
415
|
+
if (!fields.required?.includes(p.field))
|
|
416
|
+
fields.required = [...(fields.required ?? []), p.field];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
keyMemo.clear();
|
|
420
|
+
return {
|
|
421
|
+
classes, omitted: [...omittedSet], renamed, notes,
|
|
422
|
+
keyless: [...new Set([...keyless, ...forced])],
|
|
423
|
+
keyOf, family,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const quote = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
427
|
+
export async function import021(opts) {
|
|
428
|
+
const t0 = performance.now();
|
|
429
|
+
const log = opts.quiet ? () => { } : (m) => console.log(`letopis.import: ${m}`);
|
|
430
|
+
const partition = opts.partition ?? 'entity';
|
|
431
|
+
const owner = opts.owner ?? 'letopis_owner';
|
|
432
|
+
const src = typeof opts.from === 'string' ? postgres(opts.from, { max: 2, onnotice: () => { } }) : opts.from;
|
|
433
|
+
const F = quote(opts.fromSchema);
|
|
434
|
+
const S = quote(opts.schema);
|
|
435
|
+
const dst = postgres(opts.dsn, { max: 2, onnotice: () => { } });
|
|
436
|
+
let db = null;
|
|
437
|
+
try {
|
|
438
|
+
// --- чтение 0.21 ---
|
|
439
|
+
const schemaRows = await src.unsafe(`select id, alias, category, ancestor, attributes, links, meta, "order", ancestors
|
|
440
|
+
from ${F}."Schema" where partition = $1 order by cardinality(ancestors), id`, [partition]);
|
|
441
|
+
if (!schemaRows.length)
|
|
442
|
+
throw new LetopisError('invalid_data', `import: в ${opts.fromSchema} нет классов партиции ${partition}`);
|
|
443
|
+
const accounts = await src.unsafe(`select id::text as id, categories::text[] as categories, data, enabled from ${F}."Account" order by id`);
|
|
444
|
+
const objs = new Map();
|
|
445
|
+
const observed = new Map();
|
|
446
|
+
await src.unsafe(`select class, id, account::text as account, owner::text as owner, data, links, tags, deleted is not null as del,
|
|
447
|
+
to_json(updated) #>> '{}' as at
|
|
448
|
+
from ${F}."Entity" where partition = $1 order by account, class, id, updated`, [partition]).cursor(5000, (rows) => {
|
|
449
|
+
for (const r of rows) {
|
|
450
|
+
const key = `${r.class}\u0000${r.id}`;
|
|
451
|
+
let o = objs.get(key);
|
|
452
|
+
if (!o)
|
|
453
|
+
objs.set(key, (o = { cls: r.class, id: r.id, account: r.account, versions: [] }));
|
|
454
|
+
const links = (r.links ?? {});
|
|
455
|
+
o.versions.push({ data: (r.data ?? {}), links, tags: (r.tags ?? []), owner: r.owner, del: r.del, at: r.at });
|
|
456
|
+
let set = observed.get(r.class);
|
|
457
|
+
if (!set)
|
|
458
|
+
observed.set(r.class, (set = new Set()));
|
|
459
|
+
for (const k of Object.keys(links))
|
|
460
|
+
set.add(k);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
log(`0.21: классов ${schemaRows.length}, объектов ${objs.size}, аккаунтов ${accounts.length}`);
|
|
464
|
+
// --- классы ---
|
|
465
|
+
const b = buildClasses(schemaRows, observed, opts);
|
|
466
|
+
const systemOld = new Set(accounts.filter((a) => a.categories.includes('System')).map((a) => a.id));
|
|
467
|
+
systemOld.add(SYSTEM_ID);
|
|
468
|
+
const accId = (old) => (systemOld.has(old) ? SYSTEM_ID : v5Id(SYSTEM_ID, 'Account', [old]));
|
|
469
|
+
const needTenant = [...objs.values()].some((o) => systemOld.has(o.account));
|
|
470
|
+
if (needTenant && !opts.tenant)
|
|
471
|
+
throw new LetopisError('invalid_data', 'import: данные лежат у аккаунта System — укажите арендатора для них (--tenant <имя>)');
|
|
472
|
+
const sysTenant = opts.tenant ? v5Id(SYSTEM_ID, 'Account', [`tenant:${opts.tenant}`]) : null;
|
|
473
|
+
const tenantOf = (oldAccount) => (systemOld.has(oldAccount) ? sysTenant : accId(oldAccount));
|
|
474
|
+
// --- id 1.0, проверки до записи ---
|
|
475
|
+
const fatal = [];
|
|
476
|
+
const idMemo = new Map();
|
|
477
|
+
const keyVals = (o, v, parts, stack) => {
|
|
478
|
+
const out = [];
|
|
479
|
+
for (const p of parts) {
|
|
480
|
+
if ('field' in p) {
|
|
481
|
+
const cls = b.classes.get(o.cls);
|
|
482
|
+
const prop = (cls.desc.fields.properties[p.field] ?? {});
|
|
483
|
+
const val = v.data[p.field] ?? prop.default;
|
|
484
|
+
if (typeof val !== 'string')
|
|
485
|
+
return null;
|
|
486
|
+
out.push(val);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
const k = Object.keys(v.links).find((x) => b.classes.get(o.cls).roleOf.get(x) === p.end);
|
|
490
|
+
if (!k)
|
|
491
|
+
return null;
|
|
492
|
+
const t = newId(k, v.links[k], stack);
|
|
493
|
+
if (!t)
|
|
494
|
+
return null;
|
|
495
|
+
out.push(t);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
};
|
|
500
|
+
function newId(cls, old, stack = []) {
|
|
501
|
+
const key = `${cls}\u0000${old}`;
|
|
502
|
+
if (idMemo.has(key))
|
|
503
|
+
return idMemo.get(key);
|
|
504
|
+
const o = objs.get(key);
|
|
505
|
+
const parts = b.keyOf(cls);
|
|
506
|
+
if (!parts.length) {
|
|
507
|
+
if (!o)
|
|
508
|
+
return null;
|
|
509
|
+
const id = v5Id(tenantOf(o.account), b.classes.get(cls).name, [old]);
|
|
510
|
+
idMemo.set(key, id);
|
|
511
|
+
return id;
|
|
512
|
+
}
|
|
513
|
+
if (!o || stack.includes(key))
|
|
514
|
+
return null;
|
|
515
|
+
const vals = keyVals(o, o.versions[o.versions.length - 1], parts, [...stack, key]);
|
|
516
|
+
if (!vals)
|
|
517
|
+
return null;
|
|
518
|
+
const id = v5Id(tenantOf(o.account), b.classes.get(cls).name, vals);
|
|
519
|
+
idMemo.set(key, id);
|
|
520
|
+
return id;
|
|
521
|
+
}
|
|
522
|
+
const owners = new Map();
|
|
523
|
+
for (const o of objs.values()) {
|
|
524
|
+
if (!b.classes.has(o.cls)) {
|
|
525
|
+
fatal.push({ code: 'invalid_class', message: `класс ${o.cls} не найден или опущен`, class: o.cls, id: o.id });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const id = newId(o.cls, o.id);
|
|
529
|
+
if (!id) {
|
|
530
|
+
fatal.push({ code: 'target_not_found', message: 'ключ объекта ссылается на отсутствующий объект или значение ключа — не строка', class: o.cls, id: o.id });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const prev = owners.get(id);
|
|
534
|
+
if (prev) {
|
|
535
|
+
const [pc, pid] = prev.split('\u0000');
|
|
536
|
+
fatal.push({ code: 'duplicate', message: `тот же id 1.0 (${id}), что у ${pc} ${pid}: данные 0.21 нарушают ключ класса — импортируйте класс без ключа (--keyless ${b.classes.get(o.cls).old})`, class: o.cls, id: o.id });
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
owners.set(id, `${o.cls}\u0000${o.id}`);
|
|
540
|
+
}
|
|
541
|
+
const parts = b.keyOf(o.cls);
|
|
542
|
+
if (parts.length) {
|
|
543
|
+
const last = JSON.stringify(keyVals(o, o.versions[o.versions.length - 1], parts, []));
|
|
544
|
+
const changed = o.versions.findIndex((v) => JSON.stringify(keyVals(o, v, parts, [])) !== last);
|
|
545
|
+
if (changed >= 0)
|
|
546
|
+
fatal.push({ code: 'invalid_data', message: 'значение ключа менялось между версиями — в 1.0 ключ неизменен (--keyless)', class: o.cls, id: o.id, rev: changed + 1 });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (fatal.length) {
|
|
550
|
+
throw new LetopisError(fatal[0].code, `import: остановлен до записи — ${fatal[0].class} ${fatal[0].id}: ${fatal[0].message}${fatal.length > 1 ? ` (и ещё ${fatal.length - 1})` : ''}`, { rows: fatal.slice(0, 100), notes: b.notes });
|
|
551
|
+
}
|
|
552
|
+
db = await connect({ dsn: opts.dsn, schema: opts.schema, allowBypassRls: true, listen: false, max: 2, owner, cache: false });
|
|
553
|
+
const report = {
|
|
554
|
+
classes: { created: [], existing: [], omitted: b.omitted, renamed: b.renamed, keyless: b.keyless },
|
|
555
|
+
notes: b.notes, source: {}, target: {}, tenants: [],
|
|
556
|
+
accounts: { loaded: 0, skipped: 0 }, credentials: { imported: 0, skipped: 0, otp: 0, conflicts: [] },
|
|
557
|
+
resources: { loaded: 0, skipped: 0 }, rules: { loaded: 0, skipped: 0 }, objects: { loaded: 0, skipped: 0, updated: 0 },
|
|
558
|
+
uuidFields: {}, droppedFields: {}, ownerLost: 0, verify: { ok: false, issues: 0, anchor: '' }, ms: 0,
|
|
559
|
+
};
|
|
560
|
+
// --- шаг 1: классы (System, от владельца), предки раньше потомков ---
|
|
561
|
+
await dst.begin(async (tx) => {
|
|
562
|
+
await tx.unsafe(`set local role ${quote(owner)}`);
|
|
563
|
+
for (const r of schemaRows) {
|
|
564
|
+
const c = b.classes.get(r.id);
|
|
565
|
+
if (!c)
|
|
566
|
+
continue;
|
|
567
|
+
const [ex] = await tx.unsafe(`select 1 from ${S}.entity where class = 'Class' and data->>'name' = $1`, [c.name]);
|
|
568
|
+
if (ex) {
|
|
569
|
+
report.classes.existing.push(c.name);
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
await tx.unsafe(`select ${S}.sys_create(${S}.v5_id($1::uuid, 'Class', array[$2]), 'Class', $1::uuid, '{}', $3::jsonb)`, [SYSTEM_ID, c.name, c.desc]).catch((e) => {
|
|
573
|
+
e.message = `import: класс ${c.name}: ${e.message}`;
|
|
574
|
+
throw e;
|
|
575
|
+
});
|
|
576
|
+
report.classes.created.push(c.name);
|
|
577
|
+
}
|
|
578
|
+
await tx.unsafe(`select pg_notify($1, 'Class')`, [opts.schema]);
|
|
579
|
+
});
|
|
580
|
+
log(`классы: создано ${report.classes.created.length}, уже были ${report.classes.existing.length}, опущено ${b.omitted.length}`);
|
|
581
|
+
// --- шаг 2: аккаунты (System) и арендатор данных System ---
|
|
582
|
+
const accRows = [];
|
|
583
|
+
for (const a of accounts) {
|
|
584
|
+
if (systemOld.has(a.id))
|
|
585
|
+
continue;
|
|
586
|
+
const data = (a.data ?? {});
|
|
587
|
+
const name = typeof data.name === 'string' ? data.name : typeof data.id === 'string' ? data.id : a.id;
|
|
588
|
+
accRows.push({ $class: 'Account', $ext: a.id, name, categories: a.categories.filter((c) => c !== 'System'), enabled: a.enabled !== false });
|
|
589
|
+
}
|
|
590
|
+
if (sysTenant)
|
|
591
|
+
accRows.push({ $class: 'Account', $ext: `tenant:${opts.tenant}`, name: opts.tenant, categories: [], enabled: true });
|
|
592
|
+
if (accRows.length) {
|
|
593
|
+
const r = await db.load(accRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
|
|
594
|
+
report.accounts = { loaded: r.loaded, skipped: r.skipped };
|
|
595
|
+
}
|
|
596
|
+
// --- шаг 3: креды → secret (от владельца; хэши как есть); совпадение без учёта регистра — первым идёт
|
|
597
|
+
// подтверждённый, затем более ранний, остальные — в отчёт ---
|
|
598
|
+
const creds = await src.unsafe(`select account::text as account, category, identifier, meta, confirmed from ${F}."Credential" where deleted is null order by confirmed desc, created, id`);
|
|
599
|
+
const secretRows = [];
|
|
600
|
+
for (const c of creds) {
|
|
601
|
+
const m = (c.meta ?? {});
|
|
602
|
+
const account = accId(c.account);
|
|
603
|
+
const kind = String(c.category);
|
|
604
|
+
const confirmed = c.confirmed !== false;
|
|
605
|
+
if (kind === 'OTP' || kind.startsWith('OTP_') || typeof m.code === 'string') {
|
|
606
|
+
report.credentials.otp++;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (typeof m.password === 'string')
|
|
610
|
+
secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(m.password, 'utf8'), meta: { confirmed } });
|
|
611
|
+
else if (kind === 'APIKEY')
|
|
612
|
+
secretRows.push({ account, kind, ident: String(m.prefix ?? ''), material: Buffer.from(String(c.identifier), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
|
|
613
|
+
else if (kind === 'KEYSECRET')
|
|
614
|
+
secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(String(m.secret ?? ''), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
|
|
615
|
+
else if (kind === 'TOTP')
|
|
616
|
+
secretRows.push({ account, kind, ident: account, material: base32Decode(String(m.secret ?? '')), meta: { confirmed, last_step: Number(m.lastStep ?? 0) } });
|
|
617
|
+
else
|
|
618
|
+
secretRows.push({ account, kind, ident: c.identifier, material: null, meta: { ...m, confirmed } });
|
|
619
|
+
}
|
|
620
|
+
if (secretRows.length) {
|
|
621
|
+
await dst.begin(async (tx) => {
|
|
622
|
+
await tx.unsafe(`set local role ${quote(owner)}`);
|
|
623
|
+
for (const s of secretRows) {
|
|
624
|
+
const [dup] = await tx.unsafe(`select account::text as account from ${S}.secret
|
|
625
|
+
where kind = $1 and casefold(ident) collate pg_unicode_fast = casefold($2) collate pg_unicode_fast and deleted_at is null`, [s.kind, s.ident]);
|
|
626
|
+
if (dup) {
|
|
627
|
+
if (dup.account === s.account)
|
|
628
|
+
report.credentials.skipped++;
|
|
629
|
+
else
|
|
630
|
+
report.credentials.conflicts.push({ account: s.account, kind: s.kind, ident: s.ident });
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta) values ($1::uuid, $2, $3, $4, $5::jsonb)`, [s.account, s.kind, s.ident, s.material, s.meta]);
|
|
634
|
+
report.credentials.imported++;
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
// --- шаг 4: права — Resource и rule в System ---
|
|
639
|
+
const resources = await src.unsafe(`select alias, category, pattern, meta from ${F}."Resource" order by alias`);
|
|
640
|
+
const resRows = resources.map((r) => {
|
|
641
|
+
let pattern = r.pattern;
|
|
642
|
+
if (isObj(pattern) && 'account' in pattern) {
|
|
643
|
+
const { account, ...rest } = pattern;
|
|
644
|
+
pattern = { ...rest, tenant: account };
|
|
645
|
+
}
|
|
646
|
+
const meta = (r.meta ?? {});
|
|
647
|
+
return { $class: 'Resource', alias: r.alias, category: r.category, ...(isObj(pattern) ? { pattern } : {}),
|
|
648
|
+
...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
|
|
649
|
+
});
|
|
650
|
+
if (resRows.length) {
|
|
651
|
+
const r = await db.load(resRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
|
|
652
|
+
report.resources = { loaded: r.loaded, skipped: r.skipped };
|
|
653
|
+
}
|
|
654
|
+
const rules = await src.unsafe(`select account, resource, permission, weight, meta, enabled from ${F}."Rule" order by account, resource`);
|
|
655
|
+
const ruleRows = rules.map((r) => {
|
|
656
|
+
const meta = (r.meta ?? {});
|
|
657
|
+
const code = typeof meta.code === 'number' ? meta.code : typeof meta.code === 'string' && /^\d+$/.test(meta.code) ? Number(meta.code) : undefined;
|
|
658
|
+
return { $class: 'rule', Group: { $key: [r.account] }, Object: { $key: [r.resource] }, permission: r.permission, weight: r.weight ?? 0,
|
|
659
|
+
enabled: r.enabled !== false, ...(code !== undefined ? { code } : {}), ...(typeof meta.message === 'string' ? { message: meta.message } : {}),
|
|
660
|
+
...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
|
|
661
|
+
});
|
|
662
|
+
if (ruleRows.length) {
|
|
663
|
+
const r = await db.load(ruleRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
|
|
664
|
+
report.rules = { loaded: r.loaded, skipped: r.skipped };
|
|
665
|
+
}
|
|
666
|
+
// --- шаг 5: данные по арендаторам — загрузчик в режиме истории ---
|
|
667
|
+
const accountIds = new Set([SYSTEM_ID, ...accounts.map((a) => accId(a.id)), ...(sysTenant ? [sysTenant] : [])]);
|
|
668
|
+
const oldIds = new Set([...objs.values()].map((o) => o.id));
|
|
669
|
+
const byTenant = new Map();
|
|
670
|
+
for (const o of objs.values()) {
|
|
671
|
+
const t = tenantOf(o.account);
|
|
672
|
+
if (!byTenant.has(t))
|
|
673
|
+
byTenant.set(t, []);
|
|
674
|
+
byTenant.get(t).push(o);
|
|
675
|
+
}
|
|
676
|
+
for (const [tenant, list] of byTenant) {
|
|
677
|
+
const old = list[0].account;
|
|
678
|
+
report.tenants.push({ old, id: tenant, name: systemOld.has(old) ? opts.tenant : old });
|
|
679
|
+
const rows = [];
|
|
680
|
+
const origin = [];
|
|
681
|
+
const lastRows = [];
|
|
682
|
+
for (const o of list) {
|
|
683
|
+
const c = b.classes.get(o.cls);
|
|
684
|
+
const keyed = b.keyOf(o.cls).length > 0;
|
|
685
|
+
const src21 = report.source[c.name] ?? (report.source[c.name] = { objects: 0, versions: 0, live: 0 });
|
|
686
|
+
src21.objects++;
|
|
687
|
+
src21.versions += o.versions.length;
|
|
688
|
+
if (!o.versions[o.versions.length - 1].del)
|
|
689
|
+
src21.live++;
|
|
690
|
+
let afterDelete = false;
|
|
691
|
+
o.versions.forEach((v, k) => {
|
|
692
|
+
const data = {};
|
|
693
|
+
for (const [f, x] of Object.entries(v.data)) {
|
|
694
|
+
if (f === 'id')
|
|
695
|
+
continue;
|
|
696
|
+
if (f.startsWith('$')) {
|
|
697
|
+
report.droppedFields[`${c.name}.${f}`] = (report.droppedFields[`${c.name}.${f}`] ?? 0) + 1;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
data[f] = x;
|
|
701
|
+
}
|
|
702
|
+
const links = {};
|
|
703
|
+
for (const [k21, target] of Object.entries(v.links)) {
|
|
704
|
+
const role = c.roleOf.get(k21) ?? b.classes.get(k21)?.name ?? k21;
|
|
705
|
+
links[role] = newId(k21, target) ?? v5Id(tenant, b.classes.get(k21)?.name ?? k21, [`missing:${target}`]);
|
|
706
|
+
}
|
|
707
|
+
let ownerId = tenant;
|
|
708
|
+
if (v.owner && v.owner !== o.account) {
|
|
709
|
+
const mapped = systemOld.has(v.owner) ? SYSTEM_ID : accId(v.owner);
|
|
710
|
+
if (accountIds.has(mapped))
|
|
711
|
+
ownerId = mapped;
|
|
712
|
+
else if (k === o.versions.length - 1)
|
|
713
|
+
report.ownerLost++;
|
|
714
|
+
}
|
|
715
|
+
const op = v.del ? 'delete' : k === 0 || afterDelete ? 'create' : 'update';
|
|
716
|
+
afterDelete = v.del;
|
|
717
|
+
const row = { $class: c.name, $rev: k + 1, $at: v.at, $op: op, ...(keyed ? {} : { $ext: o.id }),
|
|
718
|
+
...(v.tags.length ? { $tags: v.tags } : {}), ...(ownerId !== tenant ? { $owner: ownerId } : {}), ...data, ...links };
|
|
719
|
+
rows.push(row);
|
|
720
|
+
origin.push({ class: o.cls, id: o.id, rev: k + 1 });
|
|
721
|
+
if (k === o.versions.length - 1 && !v.del) {
|
|
722
|
+
const { $rev: _r, $at: _a, $op: _o, ...cur } = row;
|
|
723
|
+
lastRows.push(cur);
|
|
724
|
+
scanUuids(data, `${c.name}`, oldIds, report.uuidFields);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
const r = await db.load(rows, { tenant, withHistory: true, existing: 'skip', agent: 'letopis import', ...(opts.commitEvery ? { commitEvery: opts.commitEvery } : {}) });
|
|
730
|
+
report.objects.loaded += r.loaded;
|
|
731
|
+
report.objects.skipped += r.skipped;
|
|
732
|
+
if (opts.update && lastRows.length) {
|
|
733
|
+
const u = await db.load(lastRows, { tenant, existing: 'update', agent: 'letopis import' });
|
|
734
|
+
report.objects.updated += u.updated;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch (e) {
|
|
738
|
+
// строки загрузчика → объекты 0.21; total, truncated, limit и complete отчёта загрузчика — как есть
|
|
739
|
+
const err = e;
|
|
740
|
+
const detail = err.detail;
|
|
741
|
+
if (detail?.rows) {
|
|
742
|
+
const mapped = detail.rows.map((x) => ({ ...origin[x.line - 1], code: x.code, message: x.message }));
|
|
743
|
+
throw new LetopisError(err.code ?? 'invalid_data', `import: арендатор ${tenant}: ${err.message}`, { ...detail, rows: mapped, notes: b.notes });
|
|
744
|
+
}
|
|
745
|
+
throw e;
|
|
746
|
+
}
|
|
747
|
+
log(`арендатор ${tenant}: объектов ${list.length}, версий ${rows.length}`);
|
|
748
|
+
}
|
|
749
|
+
// --- шаг 6: проверка — счётчики и verify() ---
|
|
750
|
+
const tenants = [...byTenant.keys()];
|
|
751
|
+
if (tenants.length) {
|
|
752
|
+
const counts = await dst.unsafe(`with l as (select class, count(distinct id)::int as objects, count(*)::int as versions from ${S}.log
|
|
753
|
+
where tenant = any ($1::uuid[]) and agent = 'letopis import' group by class),
|
|
754
|
+
e as (select class, count(*)::int as live from ${S}.entity where tenant = any ($1::uuid[]) group by class)
|
|
755
|
+
select coalesce(l.class, e.class) as class, coalesce(l.objects, 0) as objects, coalesce(l.versions, 0) as versions, coalesce(e.live, 0) as live
|
|
756
|
+
from l full join e on e.class = l.class`, [tenants]);
|
|
757
|
+
for (const r of counts)
|
|
758
|
+
report.target[r.class] = { objects: r.objects, versions: r.versions, live: r.live };
|
|
759
|
+
// классы без журнала: объектов и версий столько, сколько строк
|
|
760
|
+
for (const c of b.classes.values())
|
|
761
|
+
if (!c.history && report.target[c.name])
|
|
762
|
+
report.target[c.name] = { ...report.target[c.name], objects: report.target[c.name].live, versions: report.target[c.name].live };
|
|
763
|
+
}
|
|
764
|
+
const v = await db.verify();
|
|
765
|
+
report.verify = { ok: v.ok, issues: v.issues.length, anchor: v.anchor };
|
|
766
|
+
report.ms = performance.now() - t0;
|
|
767
|
+
log(`готово за ${(report.ms / 1000).toFixed(1)} с: verify ${v.ok ? 'чист' : `находок ${v.issues.length}`}`);
|
|
768
|
+
return report;
|
|
769
|
+
}
|
|
770
|
+
finally {
|
|
771
|
+
await db?.close();
|
|
772
|
+
await dst.end({ timeout: 5 });
|
|
773
|
+
if (typeof opts.from === 'string')
|
|
774
|
+
await src.end({ timeout: 5 });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Строки-uuid в данных, совпадающие со старыми id: «класс.путь» → число. */
|
|
778
|
+
function scanUuids(v, path, ids, out) {
|
|
779
|
+
if (typeof v === 'string') {
|
|
780
|
+
if (UUID.test(v) && ids.has(v))
|
|
781
|
+
out[path] = (out[path] ?? 0) + 1;
|
|
782
|
+
}
|
|
783
|
+
else if (Array.isArray(v)) {
|
|
784
|
+
for (const x of v)
|
|
785
|
+
scanUuids(x, `${path}[]`, ids, out);
|
|
786
|
+
}
|
|
787
|
+
else if (isObj(v)) {
|
|
788
|
+
for (const [k, x] of Object.entries(v))
|
|
789
|
+
scanUuids(x, `${path}.${k}`, ids, out);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
export { buildClasses as _buildClasses, conv as _convRule };
|