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