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.
Files changed (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. 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, removed } from './errors.js';
21
+ import { LetopisError, checkOptions, fromDbError, issuesFromDb, lerr, lmsg } from './errors.js';
22
22
  import { isOp } from './ops.js';
23
- import { Registry, aggregateFields } from './registry.js';
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 hex = (b) => (b ? '\\x' + b.toString('hex') : null);
44
- const ENTITY_COLS = 'id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator';
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
- const more = why ? ` (${rest ? `и ещё не меньше ${rest}` : 'возможно, не единственная'}: ${why})` : rest ? ` (и ещё ${rest})` : '';
76
- throw new LetopisError(first.code, `загрузка отменена: строка ${first.line} — ${first.message}${more}`, { rows: this.list, total: this.total, truncated: this.total > this.list.length, limit: ISSUES_LIMIT, complete: !why });
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 new LetopisError('invalid_data', 'load(): вход — объект «класс → строки», массив или поток строк с $class');
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: 'значение конца не uuid' };
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: 'значение конца — id, { $key } или { $ext }' };
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: 'полиморфный конец: укажите $class цели' };
116
+ return { error: lmsg('write.load_end_polymorphic') };
107
117
  const c = reg.find(name);
108
118
  if (!c || c.abstract)
109
- return { error: `класс цели ${name} не найден или абстрактный` };
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: `$key цели ${c.name} — список строк ключа [${c.key.join(', ')}]` };
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: `$ext — для класса без ключа, а у ${c.name} ключ [${c.key.join(', ')}]` };
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: '$ext — строка' };
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: `конец ${k} — список целей` } };
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: `конец ${k}: ${r.error}` } };
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: `конец ${k}: цель повторяется` } };
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: `конец ${k}: ${r.error}` } };
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 `inc() — только в update() (${path})`;
173
+ return lmsg('write.load_inc', { path });
168
174
  if (isOp(v))
169
- return `оператор фильтра в данных (${path})`;
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: `данные не проходят описание класса ${cls.name}`, issues } };
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 ? `у конца ${role} слишком мало целей` : `обязательный конец ${role} не задан` } };
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: `у конца ${role} слишком много целей` } };
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: '$ext — строка' };
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: `часть ключа ${k}: ${r.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: `часть ключа ${k} — строка` };
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
- if ('trust' in opts)
348
- throw removed('load({ trust })', 'запрос по целям вне загрузки стоит 0,1–1,3 % её времени — режим доверия убран (решение 2 точки А)');
336
+ checkOptions('load', opts, LOAD_OPTIONS);
349
337
  if (!env.admin)
350
- throw new LetopisError('admin_required', 'загрузчику нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
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 new LetopisError('invalid_data', 'load({ as }): id аккаунта');
341
+ throw lerr('write.load_as_not_uuid');
354
342
  if (asAcc !== undefined && opts.withHistory)
355
- throw new LetopisError('invalid_data', 'load({ as }) и режим истории несовместимы: историю импортирует администратор');
343
+ throw lerr('write.load_as_history');
356
344
  if (opts.withHistory && opts.existing === 'update')
357
- throw new LetopisError('invalid_data', "режим истории: existing — 'error' или 'skip' (новую версию существующего объекта пишет загрузка без истории)");
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 new LetopisError('invalid_data', 'первая заливка (initial) несовместима с commitEvery: снятые индексы и TRUNCATE требуют одной транзакции');
350
+ throw lerr('write.load_initial_commit_every');
363
351
  if (opts.existing !== undefined && opts.existing !== 'error')
364
- throw new LetopisError('invalid_data', "первая заливка (initial): existing — только 'error', в пустой схеме пропускать и обновлять нечего");
352
+ throw lerr('write.load_initial_existing');
365
353
  if (asAcc !== undefined)
366
- throw new LetopisError('invalid_data', 'первая заливка (initial) — от администратора, без { as }');
354
+ throw lerr('write.load_initial_as');
367
355
  }
368
356
  const tenant = opts.tenant ?? asAcc ?? env.tenant;
369
357
  if (!tenant)
370
- throw new LetopisError('invalid_data', 'load(): нет арендатора — сессия без арендатора и не задан { tenant }');
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' ? `класс ${name} не найден` : 'нет $class' });
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: 'строки Class задают letopis sync и create — там база компилирует проверку', class: cls.name });
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: `класс ${cls.name} абстрактный`, class: cls.name });
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: 'объект уже есть в схеме: строка системного класса или запись этой же первой заливки', id, class: cls.name });
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: `id ${id} уже был в строке ${prev + 1}`, id, class: cls.name });
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: `нет права WRITE на класс ${n}`, class: n });
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.hash, l.at::text as at from ${S}.log 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, written);
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, written, live, past);
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, written, live, past);
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), verify: opts.verify ? written : null });
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: `цель конца ${ref.role} не найдена`, id });
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: `владелец ${id} не найден`, id });
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: `цель конца ${t.role} не найдена`, id: h.id });
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: `арендатор ${tenant} не найден или отключён` });
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' ? 'объект уже есть в базе' : 'объект с этим id жил раньше и удалён (existing: update воскресит)', id: p1[i].id, class: p1[i].cls.name });
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 new LetopisError('exists', 'загрузка отменена: часть объектов уже есть в базе');
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: 'аккаунты лежат в арендаторе System', id: p1[i].id, class: cls.name });
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: 'конец Tenant членства — арендатор строки', id: p1[i].id, class: cls.name });
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: '$tags — список строк', id: p1[i].id, class: cls.name });
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 ? 'load({ as }): $owner не задаётся — владелец по ownerDefault класса' : '$owner — id аккаунта', id: p1[i].id, class: cls.name });
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: `цель конца ${role} не найдена`, id: p1[i].id, class: cls.name });
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, written, live, past) {
797
- const p = await prepareBatch(reg, part, inLoad, external, ts, xid, touched, written, live, past, 0);
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, written);
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, written, live, past) {
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, written, live, past, 256);
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
- flushing = flushBatch(conn, p, ts, touched, written);
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, written, live, past, yieldEvery) {
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, но другого класса (класс без ключа после reclass, повторный импорт по $ext): правка
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: `объект с этим id уже есть в классе ${was}: строка класса ${cls.name} его не правит`, id, class: cls.name });
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
- const ph = esc(hex(prev?.hash));
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\\N\t${ph}\t${esc(xid)}\n`);
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('условия ACL следующих пачек не проверялись');
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, written) {
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: 'строку запрещает условие ACL', id: p1[b.i].id });
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: 'правку строки запрещает условие ACL', id: r.id });
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
- // первая заливка: FREEZE — таблицы опустошены этой же транзакцией. Журнал — раньше строк: агрегатные поля новой
919
- // цели триггер после COPY досчитывает в ту же версию — и в entity, и в уже записанную версию журнала (план 1.1, §2.4)
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
- // прежние (agg_keep): их считает база
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, prev_hash = e.hash,
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, moved = null, cv = coalesce((${S}.class_row(e.class, e.tenant)).rev, e.cv)
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, moved, prev_hash)
936
- select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash from up
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
- /** Режим истории: версии волнами — после каждой волны база отдаёт хэши, они prev_hash следующей. */
948
- async function historyWrite(conn, reg, portion, inLoad, external, xid, touched, written) {
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: 'режим истории: версии с $rev подряд от 1 и $at — время версии', id: p1[l[k]].id });
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 prevHash = new Map();
977
- const lastPrev = new Map();
978
- // волна — одна вставка с returning hash: хэши волны сразу становятся prev_hash следующей (мелкий
979
- // COPY плюс отдельный запрос хэшей стоил по задержке TCP на волну)
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, ph: ph ? ph.toString('hex') : null, tx: xid });
1045
+ data: sh.data, at: r.$at, author: SYSTEM_ID, agent, op, tx: xid });
998
1046
  touched.add(cls.name);
999
- }
1000
- for (let k = 0; k < items.length; k += 5000) {
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, hex(cur.history === false ? null : lastPrev.get(id) ?? null), null].map(esc).join('\t') + '\n');
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
- * проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
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 new LetopisError('admin_required', 'загрузчику нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
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. пустая схема: кроме строк системных классов и записей reset — ничего
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 ($2::text[])) as b`, [SYSTEM_CLASSES, [...SYSTEM_CLASSES, '#reset']]);
1128
+ or exists (select 1 from ${S}.log where class <> all ($1::text[])) as b`, [SYSTEM_CLASSES]);
1091
1129
  if (busy.b) {
1092
- throw new LetopisError('not_empty', 'первая заливка — только в пустую схему: в entity и log, кроме строк системных классов (Class, Account, member, Resource, rule) и записей reset, ничего быть не должно');
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
- sql: conn,
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
- // 8. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
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
- // 10. отложенные проверки загрузок, сигнал, счётчик прав
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: `цель конца ${ref.role} не найдена`, id });
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: `владелец ${id} не найден`, id });
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: `арендатор ${p.tenant} не найден или отключён` });
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();