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/sync.js CHANGED
@@ -1,4 +1,6 @@
1
- import { LetopisError } from './errors.js';
1
+ import { lerr, lmsg } from './errors.js';
2
+ import { internalsOf } from './internal.js';
3
+ import { RESERVED_CLASS_NAMES } from './types.js';
2
4
  const ROLLBACK = Symbol('sync-dry-run');
3
5
  /** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
4
6
  function canon(v) {
@@ -9,11 +11,17 @@ function canon(v) {
9
11
  return JSON.stringify(v);
10
12
  }
11
13
  export async function syncModels(db, models, opts = {}) {
14
+ // хаб с именем метода цепочки или корня db (модель собрана мимо hub()) — отказ до записи (план 2.0, этап 4, п. 2;
15
+ // 7.10 №3); связь с таким именем отклоняет база
16
+ for (const m of models) {
17
+ if (m.kind === 'hub' && RESERVED_CLASS_NAMES.includes(m.name))
18
+ throw lerr('model.sync_hub_name_reserved', { name: m.name });
19
+ }
12
20
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
13
21
  const S = `"${db.schema.replace(/"/g, '""')}"`;
14
- // предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
15
- // Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
16
- // ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
22
+ // предок раньше потомка: потомка класса, которого ещё нет, база отклоняет. Круг родителей (модель, собранная мимо
23
+ // hub()) так не упорядочить — отказ до записи с самим кругом (хвост ревью доработок после 1.2.0: раньше обход молча
24
+ // ставил потомка раньше предка)
17
25
  const ordered = [];
18
26
  const path = [];
19
27
  const visit = (m) => {
@@ -21,23 +29,21 @@ export async function syncModels(db, models, opts = {}) {
21
29
  return;
22
30
  const at = path.indexOf(m);
23
31
  if (at >= 0) {
24
- throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
25
- + 'родителя и источник агрегата база ждёт раньше класса');
32
+ throw lerr('model.models_cycle', { cycle: [...path.slice(at), m].map((x) => x.name).join(' → ') });
26
33
  }
27
34
  path.push(m);
28
35
  if (m.parent && models.includes(m.parent))
29
36
  visit(m.parent);
30
- for (const a of Object.values(m.describe().aggregate ?? {})) {
31
- const src = models.find((x) => x.name === a.of);
32
- if (src)
33
- visit(src);
34
- }
35
37
  path.pop();
36
38
  ordered.push(m);
37
39
  };
38
40
  models.forEach(visit);
41
+ // у корня connect({ admin }) классы пишет администраторский пул с той же сессией (план 2.0, 7.9 №3): правка класса
42
+ // со строками требует прав, которых у роли приложения может не быть
43
+ const adminTx = internalsOf(db)?.adminTx;
44
+ const run = (fn) => (adminTx ? adminTx(fn) : db.transaction(fn));
39
45
  try {
40
- await db.transaction(async (tx) => {
46
+ await run(async (tx) => {
41
47
  for (const m of ordered) {
42
48
  const desc = m.describe();
43
49
  const [cur] = await tx.unsafe(`select tenant = ${S}.ctx_tenant() as own, data - 'resolved' as data from ${S}.entity
@@ -77,8 +83,12 @@ export function schemaToTs(s, indent = '') {
77
83
  return JSON.stringify(s.const);
78
84
  if (Array.isArray(s.enum))
79
85
  return s.enum.map((v) => JSON.stringify(v)).join(' | ');
80
- if (Array.isArray(s.anyOf) || Array.isArray(s.oneOf))
81
- return (s.anyOf ?? s.oneOf).map((b) => schemaToTs(b, indent)).join(' | ');
86
+ const branches = (s.anyOf ?? s.oneOf);
87
+ // ветки из одних required у объекта с полями — условие, а не форма: тип — сам объект (план 2.0, 7.5 №1)
88
+ const onlyRequired = Array.isArray(branches) && s.properties !== undefined
89
+ && branches.every((b) => Object.keys(b).every((k) => k === 'required'));
90
+ if (Array.isArray(branches) && !onlyRequired)
91
+ return branches.map((b) => schemaToTs(b, indent)).join(' | ');
82
92
  const types = Array.isArray(s.type) ? s.type : [s.type];
83
93
  return types.map((t) => {
84
94
  switch (t) {
@@ -104,20 +114,40 @@ export function schemaToTs(s, indent = '') {
104
114
  }
105
115
  }).join(' | ');
106
116
  }
107
- /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
117
+ /**
118
+ * Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
119
+ * и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
120
+ * (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
121
+ * (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
122
+ * запись каталога model.types_header.
123
+ */
108
124
  export function generateTypes(registry, opts = {}) {
109
- const out = ['// Сгенерировано letopis types — не править руками.', ''];
125
+ const out = [`// ${lmsg('model.types_header')}`, ''];
126
+ const models = [];
127
+ const lit = (v) => JSON.stringify(v);
110
128
  for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
111
129
  if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
112
130
  continue;
113
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(c.name))
114
- throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
131
+ // идентификатор TypeScript — буквы Unicode, цифры, _ и $: кириллические имена классов допустимы (план 2.0, 7.5 №1)
132
+ if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(c.name))
133
+ throw lerr('model.types_class_name', { name: c.name });
115
134
  // additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
116
135
  const { additionalProperties: _closed, ...open } = c.schema;
117
136
  void _closed;
118
- out.push(`export interface ${c.name}Data ${schemaToTs(open)}`);
119
- const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
120
- out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
137
+ // объектный литерал — интерфейс; класс без полей, union и открытая схема — псевдоним типа (иначе неверный TypeScript)
138
+ const data = schemaToTs(open);
139
+ out.push(/^\{[\s\S]*\}$/.test(data) && !/\}\s*&/.test(data) ? `export interface ${c.name}Data ${data}` : `export type ${c.name}Data = ${data};`);
140
+ const required = (e) => e.required === true || (e.min ?? 0) > 0;
141
+ const ends = Object.entries(c.ends).map(([role, e]) => ` ${lit(role)}${required(e) ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
142
+ out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`);
143
+ // концы с классами целей и предки — переходы, слоты и семейства строгих типов (typed.ts: ModelLike)
144
+ const specs = Object.entries(c.ends).map(([role, e]) => {
145
+ const targets = (Array.isArray(e.class) ? e.class : [e.class]).map(lit).join(' | ');
146
+ return ` ${lit(role)}: { targets: ${targets}; many: ${e.many === true}; required: ${required(e)} };`;
147
+ });
148
+ out.push(`export interface ${c.name}Model {`, ` readonly name: ${lit(c.name)};`, ...(c.alias ? [` readonly alias: ${lit(c.alias)};`] : []), ' readonly __types?: {', ` data: ${c.name}Data;`, ` links: ${c.name}Links;`, ` ends: {${specs.length ? `\n${specs.join('\n')}\n ` : ''}};`, ` ancestors: ${(c.ancestors.length ? c.ancestors : [c.name]).map(lit).join(' | ')};`, ' };', '}', '');
149
+ models.push(`${c.name}Model`);
121
150
  }
151
+ out.push(`export type Models = readonly [${models.join(', ')}];`, '');
122
152
  return out.join('\n');
123
153
  }
package/dist/tx.d.ts CHANGED
@@ -31,6 +31,21 @@ export declare function isTransient(e: unknown): boolean;
31
31
  export declare function isLogConflict(e: unknown): boolean;
32
32
  /** Обрыв связи: сокет закрыт или серверный процесс завершён (FATAL) — исход отправленного неизвестен. */
33
33
  export declare function isConnectionError(e: unknown): boolean;
34
+ /**
35
+ * Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
36
+ * уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
37
+ */
38
+ export declare function rollbackAfter(conn: {
39
+ unsafe(text: string): Promise<unknown>;
40
+ }, err: unknown): Promise<void>;
41
+ /**
42
+ * Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
43
+ * соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
44
+ * писала бы в сокет null — необработанный TypeError роняет процесс.
45
+ */
46
+ export declare function release(conn: {
47
+ release(): void;
48
+ }, err: unknown): void;
34
49
  export declare const RETRIES = 3;
35
50
  export declare const retryDelay: (attempt: number) => Promise<void>;
36
51
  /** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
@@ -78,6 +93,16 @@ export interface TxContext {
78
93
  passes?: AclPasses;
79
94
  /** После успешной операции (продление сессии раз в минуту). */
80
95
  after?: () => void;
96
+ /**
97
+ * Сессия кончилась (no_session): выдать новую и вписать её токен в failed — у подключения по ключу APIKEY ключ
98
+ * обменивается заново. true — операцию можно повторить (один раз). Нет — no_session как есть.
99
+ */
100
+ renew?: (failed: TxContext) => Promise<boolean>;
101
+ /**
102
+ * Перед операцией (runTx, ExplicitTx.open): после простоя подключение проверяет, жива ли сессия, — иначе чтение под
103
+ * истёкшей сессией шло бы как анонимное и молча отдавало пустой ответ. Обновляет токен ctx, если сессию выдали заново.
104
+ */
105
+ before?: (ctx: TxContext) => Promise<void>;
81
106
  /**
82
107
  * После фиксации записи: нагрузки из letopis.changed (сигналы записи, session:<тег>) — кэш
83
108
  * результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
@@ -121,7 +146,7 @@ export declare class ExplicitTx implements Executor {
121
146
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
122
147
  private raw;
123
148
  private constructor();
124
- static open(ctx: TxContext): Promise<ExplicitTx>;
149
+ static open(ctx: TxContext, renewed?: boolean): Promise<ExplicitTx>;
125
150
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
126
151
  get active(): boolean;
127
152
  run<T>(fn: (tx: Tx) => Promise<T>, opts?: {
package/dist/tx.js CHANGED
@@ -1,4 +1,4 @@
1
- import { LetopisError, fromDbError } from './errors.js';
1
+ import { LetopisError, fromDbError, lerr, lmsg } from './errors.js';
2
2
  /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
3
3
  export function isTransient(e) {
4
4
  const code = e.code;
@@ -17,23 +17,34 @@ export function isConnectionError(e) {
17
17
  const code = String(err?.code ?? '');
18
18
  return code.startsWith('CONNECTION_') || ['ECONNRESET', 'EPIPE', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNABORTED'].includes(code);
19
19
  }
20
+ /**
21
+ * Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
22
+ * уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
23
+ */
24
+ export async function rollbackAfter(conn, err) {
25
+ if (isConnectionError(err))
26
+ return;
27
+ await conn.unsafe('rollback').catch(() => { });
28
+ }
20
29
  /**
21
30
  * Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
22
31
  * соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
23
32
  * писала бы в сокет null — необработанный TypeError роняет процесс.
24
33
  */
25
- function release(conn, err) {
34
+ export function release(conn, err) {
26
35
  const e = err;
27
36
  if (e && !e.severity && isConnectionError(e))
28
37
  return;
29
38
  conn.release();
30
39
  }
31
- const commitUnknown = (cause) => Object.assign(new LetopisError('commit_unknown', 'связь оборвалась во время commit: транзакция могла зафиксироваться — '
32
- + 'проверьте исход повтором той же операции с тем же ключом'), { cause });
33
- /** Взаимная блокировка в явной транзакции: повторить нужно весь блок (подсказка, как hintTxRetry 0.21). */
40
+ const commitUnknown = (cause) => lerr('write.commit_unknown', undefined, { cause });
41
+ /**
42
+ * Взаимная блокировка в явной транзакции: повторить нужно весь блок (подсказка, как hintTxRetry 0.21). Хвост —
43
+ * запись write.tx_retry_hint; он называет db.begin(), поэтому второй раз не дописывается.
44
+ */
34
45
  function hintTxRetry(e) {
35
46
  if (isTransient(e) && e instanceof Error && !e.message.includes('db.begin()')) {
36
- e.message += ' — откатите транзакцию и повторите весь блок db.begin()';
47
+ e.message += lmsg('write.tx_retry_hint');
37
48
  }
38
49
  return e;
39
50
  }
@@ -147,6 +158,9 @@ const passSql = (q, ctx) => q.unsafe(`select set_config('letopis.acl', coalesce(
147
158
  */
148
159
  export async function runTx(ctx, fn, opts = {}) {
149
160
  const retries = opts.retries ?? RETRIES;
161
+ let renewed = false;
162
+ if (ctx.before)
163
+ await ctx.before(ctx);
150
164
  for (let attempt = 1;; attempt++) {
151
165
  let bodyDone = false;
152
166
  let dead = false;
@@ -193,13 +207,23 @@ export async function runTx(ctx, fn, opts = {}) {
193
207
  await retryDelay(attempt);
194
208
  continue;
195
209
  }
196
- throw fromDbError(e);
210
+ const le = fromDbError(e);
211
+ // сессия кончилась по простою: подключение по ключу выдаёт новую — повтор один раз, транзакция уже откачена
212
+ if (!renewed && !bodyDone && ctx.renew && isNoSession(le)) {
213
+ renewed = true;
214
+ if (await ctx.renew(ctx)) {
215
+ attempt--;
216
+ continue;
217
+ }
218
+ }
219
+ throw le;
197
220
  }
198
221
  finally {
199
222
  release(conn, failure);
200
223
  }
201
224
  }
202
225
  }
226
+ const isNoSession = (e) => e instanceof LetopisError && e.code === 'no_session';
203
227
  /** Исполнитель пула: каждая операция — своя транзакция с повторами. */
204
228
  export function poolExecutor(ctx) {
205
229
  return { explicit: false, run: (fn, opts) => runTx(ctx, fn, opts) };
@@ -223,7 +247,9 @@ export class ExplicitTx {
223
247
  this.ctx = ctx;
224
248
  this.conn = conn;
225
249
  }
226
- static async open(ctx) {
250
+ static async open(ctx, renewed = false) {
251
+ if (!renewed && ctx.before)
252
+ await ctx.before(ctx);
227
253
  const conn = await ctx.sql.reserve();
228
254
  try {
229
255
  await conn.unsafe('begin isolation level read committed');
@@ -233,9 +259,13 @@ export class ExplicitTx {
233
259
  ctx.passes.set(ctx, (await passSql(conn, ctx))[0]?.p);
234
260
  }
235
261
  catch (e) {
236
- await conn.unsafe('rollback').catch(() => { });
237
- conn.release();
238
- throw fromDbError(e);
262
+ await rollbackAfter(conn, e);
263
+ release(conn, e);
264
+ const le = fromDbError(e);
265
+ // сессия кончилась по простою (как у runTx): подключение по ключу выдаёт новую — открыть ещё раз
266
+ if (!renewed && ctx.renew && isNoSession(le) && (await ctx.renew(ctx)))
267
+ return ExplicitTx.open(ctx, true);
268
+ throw le;
239
269
  }
240
270
  return new ExplicitTx(ctx, conn);
241
271
  }
@@ -245,7 +275,7 @@ export class ExplicitTx {
245
275
  }
246
276
  async run(fn, opts) {
247
277
  if (this.done)
248
- throw new LetopisError('tx_required', 'транзакция db.begin() уже завершена');
278
+ throw lerr('write.tx_done');
249
279
  if (this.broken)
250
280
  throw this.broken;
251
281
  if (opts?.raw)
@@ -262,7 +292,7 @@ export class ExplicitTx {
262
292
  /** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
263
293
  async lock(...keys) {
264
294
  if (this.done)
265
- throw new LetopisError('tx_required', 'lock(): транзакция db.begin() уже завершена');
295
+ throw lerr('write.lock_tx_done');
266
296
  await this.run((q) => q.unsafe('select pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]));
267
297
  }
268
298
  async commit() {
@@ -294,7 +324,7 @@ export class ExplicitTx {
294
324
  }));
295
325
  changed = rows[0]?.changed ?? null;
296
326
  if (res.command === 'ROLLBACK') {
297
- this.failure = new LetopisError('tx_rolled_back', 'commit: PostgreSQL откатил транзакцию (в ней была ошибка)');
327
+ this.failure = lerr('write.tx_rolled_back');
298
328
  throw this.failure;
299
329
  }
300
330
  }