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.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/sync.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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 = [
|
|
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
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
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) =>
|
|
41
|
-
|
|
42
|
-
|
|
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 += '
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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))
|