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