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/sync.js CHANGED
@@ -1,4 +1,7 @@
1
- import { LetopisError } from './errors.js';
1
+ import { lerr, lmsg } from './errors.js';
2
+ import { internalsOf } from './internal.js';
3
+ import { NOLOG, traced } from './log.js';
4
+ import { RESERVED_CLASS_NAMES } from './types.js';
2
5
  const ROLLBACK = Symbol('sync-dry-run');
3
6
  /** Канонический JSON: ключи по алфавиту — сравнение описаний без учёта порядка ключей. */
4
7
  function canon(v) {
@@ -9,11 +12,32 @@ function canon(v) {
9
12
  return JSON.stringify(v);
10
13
  }
11
14
  export async function syncModels(db, models, opts = {}) {
15
+ // событие sync (логирование 3.0): логгер — фасада, которым пишут классы
16
+ return traced(internalsOf(db)?.log ?? NOLOG, 'sync', { op: 'syncModels' }, () => syncCore(db, models, opts), (r) => ({
17
+ rows: r.created.length + r.updated.length,
18
+ detail: { applied: r.applied, models: models.length, created: r.created, updated: r.updated, unchanged: r.unchanged.length,
19
+ skipped: r.skipped, lost: r.lost.length },
20
+ }));
21
+ }
22
+ async function syncCore(db, models, opts) {
23
+ // хаб с именем метода цепочки или корня db (модель собрана мимо hub()) — отказ до записи (план 2.0, этап 4, п. 2;
24
+ // 7.10 №3); связь с таким именем отклоняет база
25
+ for (const m of models) {
26
+ if (m.kind === 'hub' && RESERVED_CLASS_NAMES.includes(m.name))
27
+ throw lerr('model.sync_hub_name_reserved', { name: m.name });
28
+ }
12
29
  const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
30
+ // преобразования Zod (.trim(), .toLowerCase(), .transform(), z.preprocess …) база и валидатор letopis не исполняют —
31
+ // данные записались бы как есть: отказ до записи (план 2.1, A11); refine остаётся в отчёте lost
32
+ const transforms = res.lost.filter((l) => l.kind !== 'refine');
33
+ if (transforms.length) {
34
+ throw lerr('model.transform_unsupported', { rules: transforms.map((l) => `${l.path} (${l.kind})`).join(', ') }, { detail: { rules: transforms } });
35
+ }
36
+ checkEndRoles(db.registry, models);
13
37
  const S = `"${db.schema.replace(/"/g, '""')}"`;
14
- // предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
15
- // Круг (агрегат над собственным потомком, цикл агрегатов) так не упорядочить — отказ до записи с самим кругом (хвост
16
- // ревью доработок после 1.2.0: раньше обход молча ставил потомка раньше предка)
38
+ // предок раньше потомка: потомка класса, которого ещё нет, база отклоняет. Круг родителей (модель, собранная мимо
39
+ // hub()) так не упорядочить — отказ до записи с самим кругом (хвост ревью доработок после 1.2.0: раньше обход молча
40
+ // ставил потомка раньше предка)
17
41
  const ordered = [];
18
42
  const path = [];
19
43
  const visit = (m) => {
@@ -21,23 +45,21 @@ export async function syncModels(db, models, opts = {}) {
21
45
  return;
22
46
  const at = path.indexOf(m);
23
47
  if (at >= 0) {
24
- throw new LetopisError('invalid_class', `syncModels: модели зависят друг от друга по кругу: ${[...path.slice(at), m].map((x) => x.name).join(' → ')} — `
25
- + 'родителя и источник агрегата база ждёт раньше класса');
48
+ throw lerr('model.models_cycle', { cycle: [...path.slice(at), m].map((x) => x.name).join(' → ') });
26
49
  }
27
50
  path.push(m);
28
51
  if (m.parent && models.includes(m.parent))
29
52
  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
53
  path.pop();
36
54
  ordered.push(m);
37
55
  };
38
56
  models.forEach(visit);
57
+ // у корня connect({ admin }) классы пишет администраторский пул с той же сессией (план 2.0, 7.9 №3): правка класса
58
+ // со строками требует прав, которых у роли приложения может не быть
59
+ const adminTx = internalsOf(db)?.adminTx;
60
+ const run = (fn) => (adminTx ? adminTx(fn) : db.transaction(fn));
39
61
  try {
40
- await db.transaction(async (tx) => {
62
+ await run(async (tx) => {
41
63
  for (const m of ordered) {
42
64
  const desc = m.describe();
43
65
  const [cur] = await tx.unsafe(`select tenant = ${S}.ctx_tenant() as own, data - 'resolved' as data from ${S}.entity
@@ -69,6 +91,40 @@ export async function syncModels(db, models, opts = {}) {
69
91
  await db.refresh();
70
92
  return res;
71
93
  }
94
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
95
+ /** Набор классов конца — как class_set() базы: список без повторов по порядку. */
96
+ const classSet = (c) => [...new Set(typeof c === 'string' ? [c] : c)].sort().join(', ');
97
+ /**
98
+ * Имя роли конца — одно на арендатора: в любом другом классе (своём или системном) роль с тем же именем обязана вести
99
+ * на тот же набор классов (база: sql-write.end_other_class в 60-write.sql, проверка ends при записи Class). Системные
100
+ * классы занимают роли User и Tenant (member → Account), Group и Object (rule → Resource). Проверка — до записи, по
101
+ * реестру и самим моделям, с понятным текстом; база проверяет всё равно.
102
+ */
103
+ function checkEndRoles(reg, models) {
104
+ const names = new Set(models.map((m) => m.name));
105
+ const seen = new Map();
106
+ for (const c of reg.all()) {
107
+ if (names.has(c.name))
108
+ continue;
109
+ for (const [role, e] of Object.entries(c.ends)) {
110
+ if (!seen.has(role) || c.tenant === SYSTEM_ID)
111
+ seen.set(role, { cls: c.name, set: classSet(e.class), system: c.tenant === SYSTEM_ID });
112
+ }
113
+ }
114
+ for (const m of models) {
115
+ for (const [role, e] of Object.entries(m.describe().ends ?? {})) {
116
+ const mine = classSet(e.class);
117
+ const other = seen.get(role);
118
+ if (other && other.set !== mine) {
119
+ if (other.system)
120
+ throw lerr('model.end_role_system', { end: role, model: m.name, cls: other.cls, target: other.set });
121
+ throw lerr('model.end_role_taken', { end: role, model: m.name, mine, cls: other.cls, target: other.set });
122
+ }
123
+ if (!other)
124
+ seen.set(role, { cls: m.name, set: mine, system: false });
125
+ }
126
+ }
127
+ }
72
128
  /** Тип TypeScript по JSON Schema подмножества. */
73
129
  export function schemaToTs(s, indent = '') {
74
130
  if (!s || Object.keys(s).length === 0)
@@ -108,23 +164,40 @@ export function schemaToTs(s, indent = '') {
108
164
  }
109
165
  }).join(' | ');
110
166
  }
111
- /** Объявления `<Класс>Data` и `<Класс>Links` для классов реестра (кроме системных). */
167
+ /**
168
+ * Объявления `<Класс>Data` и `<Класс>Links` для классов реестра и `<Класс>Model` — имя, алиас, концы с классами целей
169
+ * и предки (ModelLike, typed.ts), — а в конце список `Models`: строгие типы цепочек без моделей Zod, `connect<Models>()`
170
+ * (план 2.0, этап 4, п. 1). Классы арендатора System (системные и классы сидов пакета) — только с includeSystem
171
+ * (`letopis types --system`); без него системные классы даёт встроенный набор SystemModels. Пометка в первой строке —
172
+ * запись каталога model.types_header.
173
+ */
112
174
  export function generateTypes(registry, opts = {}) {
113
- const out = ['// Сгенерировано letopis types — не править руками.', ''];
175
+ const out = [`// ${lmsg('model.types_header')}`, ''];
176
+ const models = [];
177
+ const lit = (v) => JSON.stringify(v);
114
178
  for (const c of registry.all().sort((a, b) => a.name.localeCompare(b.name))) {
115
179
  if (!opts.includeSystem && c.tenant === '2eba6d0a-1edd-4bb6-a85a-a703dab49035')
116
180
  continue;
117
181
  // идентификатор TypeScript — буквы Unicode, цифры, _ и $: кириллические имена классов допустимы (план 2.0, 7.5 №1)
118
182
  if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(c.name))
119
- throw new LetopisError('invalid_class', `types: имя класса ${c.name} не годится для идентификатора TypeScript`);
183
+ throw lerr('model.types_class_name', { name: c.name });
120
184
  // additionalProperties: false верхнего уровня в тип не переносится — лишние ключи и так ошибка типов
121
185
  const { additionalProperties: _closed, ...open } = c.schema;
122
186
  void _closed;
123
187
  // объектный литерал — интерфейс; класс без полей, union и открытая схема — псевдоним типа (иначе неверный TypeScript)
124
188
  const data = schemaToTs(open);
125
189
  out.push(/^\{[\s\S]*\}$/.test(data) && !/\}\s*&/.test(data) ? `export interface ${c.name}Data ${data}` : `export type ${c.name}Data = ${data};`);
126
- const ends = Object.entries(c.ends).map(([role, e]) => ` ${JSON.stringify(role)}${e.required || (e.min ?? 0) > 0 ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
127
- out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`, '');
190
+ const required = (e) => e.required === true || (e.min ?? 0) > 0;
191
+ const ends = Object.entries(c.ends).map(([role, e]) => ` ${lit(role)}${required(e) ? '' : '?'}: ${e.many ? 'string[]' : 'string'};`);
192
+ out.push(`export interface ${c.name}Links {${ends.length ? `\n${ends.join('\n')}\n` : ''}}`);
193
+ // концы с классами целей и предки — переходы, слоты и семейства строгих типов (typed.ts: ModelLike)
194
+ const specs = Object.entries(c.ends).map(([role, e]) => {
195
+ const targets = (Array.isArray(e.class) ? e.class : [e.class]).map(lit).join(' | ');
196
+ return ` ${lit(role)}: { targets: ${targets}; many: ${e.many === true}; required: ${required(e)} };`;
197
+ });
198
+ 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(' | ')};`, ' };', '}', '');
199
+ models.push(`${c.name}Model`);
128
200
  }
201
+ out.push(`export type Models = readonly [${models.join(', ')}];`, '');
129
202
  return out.join('\n');
130
203
  }
package/dist/tx.d.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  * записи. Неотправленное (ошибка отправки) остаётся в очереди до следующего интервала.
24
24
  */
25
25
  import type postgres from 'postgres';
26
+ import { type Log } from './log.js';
26
27
  type Sql = postgres.Sql<Record<string, unknown>>;
27
28
  export type Tx = postgres.TransactionSql<Record<string, unknown>>;
28
29
  /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
@@ -54,11 +55,12 @@ export declare class Notifier {
54
55
  private channel;
55
56
  private intervalMs;
56
57
  private onError;
58
+ private log;
57
59
  private queue;
58
60
  private timer;
59
61
  private stopped;
60
62
  private flushing;
61
- constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void);
63
+ constructor(sql: Sql, channel: string, intervalMs: number, onError?: (e: unknown) => void, log?: Log);
62
64
  /** Добавить нагрузки из letopis.changed (строка через запятую). */
63
65
  add(changed: string | null | undefined): void;
64
66
  private schedule;
@@ -93,11 +95,23 @@ export interface TxContext {
93
95
  passes?: AclPasses;
94
96
  /** После успешной операции (продление сессии раз в минуту). */
95
97
  after?: () => void;
98
+ /**
99
+ * Сессия кончилась (no_session): выдать новую и вписать её токен в failed — у подключения по ключу APIKEY ключ
100
+ * обменивается заново. true — операцию можно повторить (один раз). Нет — no_session как есть.
101
+ */
102
+ renew?: (failed: TxContext) => Promise<boolean>;
103
+ /**
104
+ * Перед операцией (runTx, ExplicitTx.open): после простоя подключение проверяет, жива ли сессия, — иначе чтение под
105
+ * истёкшей сессией шло бы как анонимное и молча отдавало пустой ответ. Обновляет токен ctx, если сессию выдали заново.
106
+ */
107
+ before?: (ctx: TxContext) => Promise<void>;
96
108
  /**
97
109
  * После фиксации записи: нагрузки из letopis.changed (сигналы записи, session:<тег>) — кэш
98
110
  * результатов сбрасывает свои ответы сразу (§2.12); raw — запись через db.sql.
99
111
  */
100
112
  onChanged?: (names: string[], raw: boolean) => void;
113
+ /** Логгер (lib/src/log.ts): повторы (tx.retry), транзакции db.begin() (tx.begin, tx.commit, tx.rollback). */
114
+ log?: Log;
101
115
  }
102
116
  /**
103
117
  * Операция одной транзакцией с контекстом сессии и повтором при транзиентных ошибках и конфликте
@@ -135,8 +149,11 @@ export declare class ExplicitTx implements Executor {
135
149
  private failure;
136
150
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
137
151
  private raw;
152
+ /** Время открытия — для ms у tx.commit и tx.rollback. */
153
+ private readonly t0;
138
154
  private constructor();
139
- static open(ctx: TxContext): Promise<ExplicitTx>;
155
+ private get log();
156
+ static open(ctx: TxContext, renewed?: boolean): Promise<ExplicitTx>;
140
157
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
141
158
  get active(): boolean;
142
159
  run<T>(fn: (tx: Tx) => Promise<T>, opts?: {
@@ -146,6 +163,8 @@ export declare class ExplicitTx implements Executor {
146
163
  /** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
147
164
  lock(...keys: (string | number)[]): Promise<void>;
148
165
  commit(): Promise<void>;
166
+ /** Событие отката (tx.rollback) и ошибка, которой он вызван. */
167
+ private rolledBack;
149
168
  rollback(): Promise<void>;
150
169
  }
151
170
  export {};
package/dist/tx.js CHANGED
@@ -1,4 +1,5 @@
1
- import { LetopisError, fromDbError } from './errors.js';
1
+ import { LetopisError, fromDbError, lerr, lmsg } from './errors.js';
2
+ import { NOLOG } from './log.js';
2
3
  /** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
3
4
  export function isTransient(e) {
4
5
  const code = e.code;
@@ -37,12 +38,14 @@ export function release(conn, err) {
37
38
  return;
38
39
  conn.release();
39
40
  }
40
- const commitUnknown = (cause) => Object.assign(new LetopisError('commit_unknown', 'связь оборвалась во время commit: транзакция могла зафиксироваться — '
41
- + 'проверьте исход повтором той же операции с тем же ключом'), { cause });
42
- /** Взаимная блокировка в явной транзакции: повторить нужно весь блок (подсказка, как hintTxRetry 0.21). */
41
+ const commitUnknown = (cause) => lerr('write.commit_unknown', undefined, { cause });
42
+ /**
43
+ * Взаимная блокировка в явной транзакции: повторить нужно весь блок (подсказка, как hintTxRetry 0.21). Хвост —
44
+ * запись write.tx_retry_hint; он называет db.begin(), поэтому второй раз не дописывается.
45
+ */
43
46
  function hintTxRetry(e) {
44
47
  if (isTransient(e) && e instanceof Error && !e.message.includes('db.begin()')) {
45
- e.message += ' — откатите транзакцию и повторите весь блок db.begin()';
48
+ e.message += lmsg('write.tx_retry_hint');
46
49
  }
47
50
  return e;
48
51
  }
@@ -54,15 +57,17 @@ export class Notifier {
54
57
  channel;
55
58
  intervalMs;
56
59
  onError;
60
+ log;
57
61
  queue = new Set();
58
62
  timer = null;
59
63
  stopped = false;
60
64
  flushing = Promise.resolve();
61
- constructor(sql, channel, intervalMs, onError = () => { }) {
65
+ constructor(sql, channel, intervalMs, onError = () => { }, log = NOLOG) {
62
66
  this.sql = sql;
63
67
  this.channel = channel;
64
68
  this.intervalMs = intervalMs;
65
69
  this.onError = onError;
70
+ this.log = log;
66
71
  }
67
72
  /** Добавить нагрузки из letopis.changed (строка через запятую). */
68
73
  add(changed) {
@@ -87,7 +92,10 @@ export class Notifier {
87
92
  return this.flushing;
88
93
  const payloads = [...this.queue];
89
94
  this.queue.clear();
90
- this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => undefined, (e) => {
95
+ this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => {
96
+ if (this.log.wants('signal.send'))
97
+ this.log.emit('signal.send', { rows: payloads.length });
98
+ }, (e) => {
91
99
  // не отправлено (обрыв связи, отказ базы) — обратно в очередь: уйдёт следующим интервалом
92
100
  // (в 1.0.1 такие сигналы терялись, и кэш других процессов устаревал до срока)
93
101
  for (const c of payloads)
@@ -156,6 +164,9 @@ const passSql = (q, ctx) => q.unsafe(`select set_config('letopis.acl', coalesce(
156
164
  */
157
165
  export async function runTx(ctx, fn, opts = {}) {
158
166
  const retries = opts.retries ?? RETRIES;
167
+ let renewed = false;
168
+ if (ctx.before)
169
+ await ctx.before(ctx);
159
170
  for (let attempt = 1;; attempt++) {
160
171
  let bodyDone = false;
161
172
  let dead = false;
@@ -199,16 +210,31 @@ export async function runTx(ctx, fn, opts = {}) {
199
210
  if (bodyDone && !opts.read && dead)
200
211
  throw commitUnknown(e);
201
212
  if (attempt <= retries && (isTransient(e) || isLogConflict(e))) {
213
+ const log = ctx.log ?? NOLOG;
214
+ if (log.wants('tx.retry')) {
215
+ log.emit('tx.retry', { detail: { attempt, of: retries, sqlstate: e.code,
216
+ ...(isLogConflict(e) ? { conflict: 'log_id_rev' } : {}) } });
217
+ }
202
218
  await retryDelay(attempt);
203
219
  continue;
204
220
  }
205
- throw fromDbError(e);
221
+ const le = fromDbError(e);
222
+ // сессия кончилась по простою: подключение по ключу выдаёт новую — повтор один раз, транзакция уже откачена
223
+ if (!renewed && !bodyDone && ctx.renew && isNoSession(le)) {
224
+ renewed = true;
225
+ if (await ctx.renew(ctx)) {
226
+ attempt--;
227
+ continue;
228
+ }
229
+ }
230
+ throw le;
206
231
  }
207
232
  finally {
208
233
  release(conn, failure);
209
234
  }
210
235
  }
211
236
  }
237
+ const isNoSession = (e) => e instanceof LetopisError && e.code === 'no_session';
212
238
  /** Исполнитель пула: каждая операция — своя транзакция с повторами. */
213
239
  export function poolExecutor(ctx) {
214
240
  return { explicit: false, run: (fn, opts) => runTx(ctx, fn, opts) };
@@ -228,11 +254,18 @@ export class ExplicitTx {
228
254
  failure = null;
229
255
  /** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
230
256
  raw = false;
257
+ /** Время открытия — для ms у tx.commit и tx.rollback. */
258
+ t0 = performance.now();
231
259
  constructor(ctx, conn) {
232
260
  this.ctx = ctx;
233
261
  this.conn = conn;
234
262
  }
235
- static async open(ctx) {
263
+ get log() {
264
+ return this.ctx.log ?? NOLOG;
265
+ }
266
+ static async open(ctx, renewed = false) {
267
+ if (!renewed && ctx.before)
268
+ await ctx.before(ctx);
236
269
  const conn = await ctx.sql.reserve();
237
270
  try {
238
271
  await conn.unsafe('begin isolation level read committed');
@@ -244,8 +277,15 @@ export class ExplicitTx {
244
277
  catch (e) {
245
278
  await rollbackAfter(conn, e);
246
279
  release(conn, e);
247
- throw fromDbError(e);
280
+ const le = fromDbError(e);
281
+ // сессия кончилась по простою (как у runTx): подключение по ключу выдаёт новую — открыть ещё раз
282
+ if (!renewed && ctx.renew && isNoSession(le) && (await ctx.renew(ctx)))
283
+ return ExplicitTx.open(ctx, true);
284
+ throw le;
248
285
  }
286
+ const log = ctx.log ?? NOLOG;
287
+ if (log.wants('tx.begin'))
288
+ log.emit('tx.begin', { op: 'begin', detail: { isolation: 'read committed', ...(ctx.reason ? { reason: ctx.reason } : {}) } });
249
289
  return new ExplicitTx(ctx, conn);
250
290
  }
251
291
  /** Транзакция ещё открыта (не зафиксирована и не откачена). */
@@ -254,7 +294,7 @@ export class ExplicitTx {
254
294
  }
255
295
  async run(fn, opts) {
256
296
  if (this.done)
257
- throw new LetopisError('tx_required', 'транзакция db.begin() уже завершена');
297
+ throw lerr('write.tx_done');
258
298
  if (this.broken)
259
299
  throw this.broken;
260
300
  if (opts?.raw)
@@ -271,7 +311,7 @@ export class ExplicitTx {
271
311
  /** Advisory-блокировка до конца транзакции (отдельное от блокировок семейств пространство ключей). */
272
312
  async lock(...keys) {
273
313
  if (this.done)
274
- throw new LetopisError('tx_required', 'lock(): транзакция db.begin() уже завершена');
314
+ throw lerr('write.lock_tx_done');
275
315
  await this.run((q) => q.unsafe('select pg_advisory_xact_lock(hashtextextended($1, 0))', [keys.join('|')]));
276
316
  }
277
317
  async commit() {
@@ -286,6 +326,7 @@ export class ExplicitTx {
286
326
  await this.conn.unsafe('rollback').catch(() => { });
287
327
  release(this.conn, this.broken);
288
328
  this.failure = this.broken;
329
+ this.rolledBack('error', this.broken);
289
330
  throw this.broken;
290
331
  }
291
332
  let changed = null;
@@ -303,25 +344,47 @@ export class ExplicitTx {
303
344
  }));
304
345
  changed = rows[0]?.changed ?? null;
305
346
  if (res.command === 'ROLLBACK') {
306
- this.failure = new LetopisError('tx_rolled_back', 'commit: PostgreSQL откатил транзакцию (в ней была ошибка)');
347
+ this.failure = lerr('write.tx_rolled_back');
307
348
  throw this.failure;
308
349
  }
309
350
  }
310
351
  catch (e) {
311
352
  this.failure ??= isConnectionError(e) ? commitUnknown(e) : fromDbError(e);
312
353
  release(this.conn, e);
354
+ this.rolledBack('error', this.failure);
313
355
  throw this.failure;
314
356
  }
315
357
  this.conn.release();
358
+ // транзакция с записью (write, delete, restore, db.sql, права, креды) — tx.commit уровня info: это и есть
359
+ // доказательство фиксации её событий записи (у них то же поле tx); без записи — debug, как прежде
360
+ const writes = this.log.writes ?? 0;
361
+ const level = writes > 0 ? 'info' : 'debug';
362
+ if (this.log.level(level)) {
363
+ this.log.emit('tx.commit', { op: 'commit', level, ms: performance.now() - this.t0,
364
+ detail: { writes, ...(changed ? { signals: changed.split(',').filter(Boolean).length } : {}) } });
365
+ }
316
366
  this.ctx.notifier?.add(changed);
317
367
  if (changed)
318
368
  this.ctx.onChanged?.(changedList(changed), this.raw);
319
369
  this.ctx.after?.();
320
370
  }
371
+ /** Событие отката (tx.rollback) и ошибка, которой он вызван. */
372
+ rolledBack(reason, err) {
373
+ const log = this.log;
374
+ if (!log.enabled)
375
+ return;
376
+ if (err !== undefined)
377
+ log.error(err, { op: 'commit', ms: performance.now() - this.t0 });
378
+ if (log.wants('tx.rollback')) {
379
+ log.emit('tx.rollback', { op: reason === 'error' ? 'commit' : 'rollback', ms: performance.now() - this.t0,
380
+ detail: { reason, writes: log.writes ?? 0, ...(this.broken ? { broken: true } : {}) } });
381
+ }
382
+ }
321
383
  async rollback() {
322
384
  if (this.done)
323
385
  return;
324
386
  this.done = true;
387
+ this.rolledBack('explicit');
325
388
  try {
326
389
  // соединение, оборванное ошибкой, не трогаем: postgres.js уронил бы процесс записью в закрытый сокет
327
390
  if (!isConnectionError(this.broken))