letopis 2.0.0 → 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 +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- 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 +5 -1
package/dist/write.js
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
* строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
|
|
18
18
|
* разошёлся со { rev } — conflict.
|
|
19
19
|
*/
|
|
20
|
-
import { lerr, ValidationError } from './errors.js';
|
|
20
|
+
import { fromDbError, issuesFromDb, lerr, lmsg, LetopisError, ValidationError } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
22
|
import { accepts, buildRead, nextNodeKey, precheckStep, resolveHop, stepClasses, toRow } from './sql.js';
|
|
23
23
|
import { idOf } from './uuid.js';
|
|
24
|
-
import { applyDefaults } from './validate.js';
|
|
24
|
+
import { applyDefaults, leavesToDb } from './validate.js';
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Приращение: маркер, которого не получить из JSON (§2.5)
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +36,29 @@ export function inc(by, opts = {}) {
|
|
|
36
36
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
37
37
|
}
|
|
38
38
|
export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
|
|
39
|
+
/**
|
|
40
|
+
* Данные create() и upsert() без ключей со значением undefined — как у патча update() (splitPatch): ключ undefined JSON
|
|
41
|
+
* не передаёт, а валидатор на TypeScript принимал его за поле не того типа (план 2.1, A12). Вложенные объекты — тоже;
|
|
42
|
+
* массивы, даты, маркеры и операторы — как есть.
|
|
43
|
+
*/
|
|
44
|
+
export function dropUndefined(data) {
|
|
45
|
+
if (!isPlain(data))
|
|
46
|
+
return data;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [k, v] of Object.entries(data)) {
|
|
49
|
+
if (v === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
out[k] = isPlain(v) ? dropUndefined(v) : v;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** expect — список строк { id, rev } (и $action у превью удаления): иначе invalid_query до записи. */
|
|
56
|
+
function checkExpect(expect, verb) {
|
|
57
|
+
const ok = Array.isArray(expect) && expect.every((x) => x !== null && typeof x === 'object' && typeof x.id === 'string'
|
|
58
|
+
&& Number.isInteger(x.rev) && x.rev >= 1);
|
|
59
|
+
if (!ok)
|
|
60
|
+
throw lerr('write.bad_expect', { verb });
|
|
61
|
+
}
|
|
39
62
|
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
40
63
|
export function checkReason(v, where) {
|
|
41
64
|
if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
|
|
@@ -44,7 +67,9 @@ export function checkReason(v, where) {
|
|
|
44
67
|
return v;
|
|
45
68
|
}
|
|
46
69
|
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
47
|
-
|
|
70
|
+
function isPlain(v) {
|
|
71
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
|
|
72
|
+
}
|
|
48
73
|
/** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
|
|
49
74
|
export function splitPatch(patch) {
|
|
50
75
|
const incs = [];
|
|
@@ -97,6 +122,51 @@ export function splitPatch(patch) {
|
|
|
97
122
|
// ---------------------------------------------------------------------------
|
|
98
123
|
// Проверки до отправки (precheckPlan)
|
|
99
124
|
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Нарушения, найденные валидатором на TypeScript у класса с правилами только для базы (`pattern`): полный список даст
|
|
127
|
+
* база (completeIssues). Ключ — сама ошибка.
|
|
128
|
+
*/
|
|
129
|
+
const PENDING = new WeakMap();
|
|
130
|
+
/**
|
|
131
|
+
* Ключи данных, которые не поля класса, а его концы (связи): имя роли конца или имя класса на конце (у роли с другим
|
|
132
|
+
* именем). Пары [ключ, роль] — для текста «задайте .Роль.set(id)» (write.link_in_data, 3.0): подсказка в самом тексте
|
|
133
|
+
* ошибки, а не только в err.hint.
|
|
134
|
+
*/
|
|
135
|
+
function endsAsFields(cls, data, skip) {
|
|
136
|
+
const props = (cls.schema.properties ?? {});
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const k of Object.keys(data)) {
|
|
139
|
+
if (data[k] === undefined || k in props || k === skip)
|
|
140
|
+
continue;
|
|
141
|
+
if (cls.ends[k] !== undefined) {
|
|
142
|
+
out.push([k, k]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const role = Object.entries(cls.ends).find(([, e]) => (Array.isArray(e.class) ? e.class : [e.class]).includes(k))?.[0];
|
|
146
|
+
if (role !== undefined)
|
|
147
|
+
out.push([k, role]);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Ключ и параметры ошибки «конец передан полем данных» (write.link_in_data). */
|
|
152
|
+
function linkInData(cls, verb, found) {
|
|
153
|
+
return { key: 'write.link_in_data', params: { verb, cls: cls.name, keys: found.map(([k]) => k).join(', '), role: found[0][1] } };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Патч update() с ключом — именем конца класса, которого нет среди полей: база отклонила бы его (лишний ключ верхнего
|
|
157
|
+
* уровня) — отказ до записи с подсказкой «конец — из пути или .Роль.set()» (план 2.1, A8). data.id цели — не данные.
|
|
158
|
+
*/
|
|
159
|
+
function checkPatchEnds(s, patch) {
|
|
160
|
+
const cls = s.cls;
|
|
161
|
+
if (cls.name === 'Class' || cls.abstract || cls.schema.additionalProperties !== false)
|
|
162
|
+
return;
|
|
163
|
+
const useDataId = s.filter === undefined && typeof patch.id === 'string';
|
|
164
|
+
const found = endsAsFields(cls, patch, useDataId ? 'id' : undefined);
|
|
165
|
+
if (!found.length)
|
|
166
|
+
return;
|
|
167
|
+
const issue = { path: [], keyword: 'additionalProperties', message: lmsg('model.issue_additional', { keys: found.map(([k]) => k).join(', ') }) };
|
|
168
|
+
throw new ValidationError(cls.name, [issue], linkInData(cls, 'update', found));
|
|
169
|
+
}
|
|
100
170
|
/** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
|
|
101
171
|
function checkFullData(cls, data, verb) {
|
|
102
172
|
if (!isPlain(data))
|
|
@@ -116,9 +186,57 @@ function checkFullData(cls, data, verb) {
|
|
|
116
186
|
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
117
187
|
if (cls.name === 'Class' || cls.abstract)
|
|
118
188
|
return;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
189
|
+
const v = cls.validator();
|
|
190
|
+
const full = applyDefaults(cls.schema, data);
|
|
191
|
+
const issues = v(full);
|
|
192
|
+
if (!issues.length)
|
|
193
|
+
return;
|
|
194
|
+
const found = endsAsFields(cls, data);
|
|
195
|
+
const e = new ValidationError(cls.name, issues, found.length ? linkInData(cls, verb, found) : undefined);
|
|
196
|
+
if (leavesToDb(v))
|
|
197
|
+
PENDING.set(e, { cls: cls.name, data: full });
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Ошибка данных до записи у класса с правилами только для базы (`pattern`): валидатор на TypeScript нашёл не всё — полный
|
|
202
|
+
* список нарушений даёт база одним запросом (`validate_row`, план 2.1, A8), под точкой сохранения: неудача запроса
|
|
203
|
+
* (схема прежней ревизии) не ломает db.begin(), остаётся прежняя ошибка. Прочие ошибки — как есть.
|
|
204
|
+
*/
|
|
205
|
+
export async function completeIssues(ctx, e) {
|
|
206
|
+
const p = e instanceof ValidationError ? PENDING.get(e) : undefined;
|
|
207
|
+
if (!p)
|
|
208
|
+
return e;
|
|
209
|
+
const S = quoteId(ctx.schema);
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = await ctx.exec.run(async (q) => {
|
|
213
|
+
await q.unsafe('savepoint letopis_issues');
|
|
214
|
+
try {
|
|
215
|
+
const [r] = (await q.unsafe(`select ${S}.validate_row($1::text, $2::jsonb) as v`, [p.cls, p.data]));
|
|
216
|
+
await q.unsafe('release savepoint letopis_issues');
|
|
217
|
+
return r?.v;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
await q.unsafe('rollback to savepoint letopis_issues');
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}, { read: true });
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return e;
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(raw) || !raw.length)
|
|
229
|
+
return e;
|
|
230
|
+
const v = e;
|
|
231
|
+
return Object.assign(new ValidationError(p.cls, issuesFromDb(raw), v.key ? { key: v.key, params: v.params } : undefined), { cause: e });
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Блокировка строк в порядке id (lockedRead, updateOp — for no key update; deleteOp с expect — for update): id, которые
|
|
235
|
+
* удалось заблокировать.
|
|
236
|
+
*/
|
|
237
|
+
export async function lockIds(q, S, ids, mode = 'no key update') {
|
|
238
|
+
const rows = (await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for ${mode}`, [ids], { prepare: true }));
|
|
239
|
+
return new Set(rows.map((r) => r.id));
|
|
122
240
|
}
|
|
123
241
|
/** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
|
|
124
242
|
const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
|
|
@@ -139,6 +257,13 @@ function narrowed(steps, i) {
|
|
|
139
257
|
}
|
|
140
258
|
return false;
|
|
141
259
|
}
|
|
260
|
+
/** Шаги сегмента глагола шага i: от шага после прежнего глагола до него самого. */
|
|
261
|
+
function segmentOf(steps, i) {
|
|
262
|
+
let j = i - 1;
|
|
263
|
+
while (j >= 0 && !steps[j].op)
|
|
264
|
+
j--;
|
|
265
|
+
return steps.slice(j + 1, i + 1);
|
|
266
|
+
}
|
|
142
267
|
/**
|
|
143
268
|
* Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
|
|
144
269
|
* (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
|
|
@@ -161,8 +286,9 @@ export function precheckPlan(reg, steps, order, agg) {
|
|
|
161
286
|
const m = s.opMods ?? {};
|
|
162
287
|
if (m.asOf !== undefined)
|
|
163
288
|
throw lerr('write.as_of_write', { verb });
|
|
164
|
-
// restore сам ищет удалённые объекты шага
|
|
165
|
-
|
|
289
|
+
// restore сам ищет удалённые объекты шага; withDeleted() — модификатор шага (план 2.1, A9): у шага глагола или шага
|
|
290
|
+
// до него в сегменте — отказ, как прежде у модификатора цепочки
|
|
291
|
+
if (verb !== 'restore' && segmentOf(steps, i).some((x) => x.withDeleted))
|
|
166
292
|
throw lerr('write.with_deleted_write', { verb });
|
|
167
293
|
switch (op.kind) {
|
|
168
294
|
case 'create':
|
|
@@ -185,6 +311,7 @@ export function precheckPlan(reg, steps, order, agg) {
|
|
|
185
311
|
if (!isPlain(op.data))
|
|
186
312
|
throw lerr('write.patch_not_object');
|
|
187
313
|
splitPatch(op.data);
|
|
314
|
+
checkPatchEnds(s, op.data);
|
|
188
315
|
if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
|
|
189
316
|
throw lerr('write.bad_rev');
|
|
190
317
|
// строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
|
|
@@ -194,11 +321,28 @@ export function precheckPlan(reg, steps, order, agg) {
|
|
|
194
321
|
}
|
|
195
322
|
break;
|
|
196
323
|
case 'delete':
|
|
197
|
-
// превью (без confirm) ничего не удаляет — его не сужают
|
|
324
|
+
// превью (без confirm) ничего не удаляет — его не сужают и не сверяют
|
|
325
|
+
if (op.expect !== undefined) {
|
|
326
|
+
if (!op.confirm)
|
|
327
|
+
throw lerr('write.expect_without_confirm');
|
|
328
|
+
checkExpect(op.expect, verb);
|
|
329
|
+
}
|
|
330
|
+
// 3.0: удаление сверяется с превью, которое видел пользователь (expect); без сверки — только осознанно, { all: true }
|
|
331
|
+
if (op.confirm && op.expect === undefined && !op.all)
|
|
332
|
+
throw lerr('write.confirm_without_expect');
|
|
198
333
|
if (op.confirm && !op.all && !narrowed(steps, i))
|
|
199
|
-
throw lerr('write.no_filter', { call: 'delete({ confirm: true })', cls: s.cls.name });
|
|
334
|
+
throw lerr('write.no_filter', { call: 'delete({ confirm: true, expect })', cls: s.cls.name });
|
|
200
335
|
break;
|
|
201
336
|
case 'restore':
|
|
337
|
+
// partial (второй раунд 2.1, A3) — только с expect: пропуск считается по показанным надгробиям
|
|
338
|
+
if (op.partial && op.expect === undefined)
|
|
339
|
+
throw lerr('write.partial_without_expect');
|
|
340
|
+
if (op.expect !== undefined)
|
|
341
|
+
checkExpect(op.expect, verb);
|
|
342
|
+
// 3.0: без expect — отказ, и для явных id тоже: restore вернул бы последнее надгробие, а не то, что видел
|
|
343
|
+
// пользователь (объект могли восстановить, поменять и удалить снова); вернуть всё без сверки — { all: true }
|
|
344
|
+
else if (!op.all)
|
|
345
|
+
throw lerr('write.restore_without_expect');
|
|
202
346
|
break;
|
|
203
347
|
}
|
|
204
348
|
for (const sl of s.slots ?? []) {
|
|
@@ -349,7 +493,8 @@ async function slotIds(w, v) {
|
|
|
349
493
|
// отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
|
|
350
494
|
precheckPlan(w.reg, v.plan);
|
|
351
495
|
const r = await execPlan(w, v.plan, v.mods, 'rows');
|
|
352
|
-
|
|
496
|
+
// чтение хвоста отдаёт строки в обёртке { row } (readIn, режим 'rows') — как в батче, распаковать
|
|
497
|
+
rows = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
|
|
353
498
|
}
|
|
354
499
|
else {
|
|
355
500
|
rows = await readRows(w, v.plan, v.mods);
|
|
@@ -462,6 +607,11 @@ function inTargetOrder(ids, rows) {
|
|
|
462
607
|
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
463
608
|
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
464
609
|
}
|
|
610
|
+
/** Шаги сужены только по id: фильтр — id или список id, без tags(), owner() и обхода deep(). */
|
|
611
|
+
function idsOnly(steps) {
|
|
612
|
+
return steps.every((s) => (s.filter === undefined || typeof s.filter === 'string' || (Array.isArray(s.filter) && s.filter.every((x) => typeof x === 'string')))
|
|
613
|
+
&& s.tagsFilter === undefined && s.ownerFilter === undefined);
|
|
614
|
+
}
|
|
465
615
|
/** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
466
616
|
async function targetIds(w, steps, opStep, dataId) {
|
|
467
617
|
const path = steps.map(bare);
|
|
@@ -478,7 +628,15 @@ async function updateOp(w, steps, opStep) {
|
|
|
478
628
|
// data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
|
|
479
629
|
const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
|
|
480
630
|
const { id: dataId, ...data } = op.data;
|
|
481
|
-
|
|
631
|
+
let ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
632
|
+
// цели сужены не только по id (данные, концы, tags(), owner()): как lockedRead — блокировка целей и то же чтение заново;
|
|
633
|
+
// правятся только строки, которые и после ожидания блокировки подходят под условия (план 2.1, A4). Без гонки — те же
|
|
634
|
+
// цели; строка, которую не дала заблокировать RLS, остаётся в целях — её объяснит explainMissing
|
|
635
|
+
if (ids.length && !idsOnly(steps)) {
|
|
636
|
+
const locked = await lockIds(w.q, S, ids);
|
|
637
|
+
const fresh = new Set(await targetIds(w, steps, opStep, useDataId ? dataId : undefined));
|
|
638
|
+
ids = ids.filter((id) => fresh.has(id) || !locked.has(id));
|
|
639
|
+
}
|
|
482
640
|
if (op.rev !== undefined) {
|
|
483
641
|
if (ids.length > 1)
|
|
484
642
|
throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
|
|
@@ -508,24 +666,53 @@ async function updateOp(w, steps, opStep) {
|
|
|
508
666
|
}
|
|
509
667
|
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
510
668
|
}
|
|
669
|
+
/** Превью удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. */
|
|
670
|
+
async function previewOf(w, ids) {
|
|
671
|
+
const S = quoteId(w.schema);
|
|
672
|
+
const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
|
|
673
|
+
+ `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
|
|
674
|
+
return res.map((r) => {
|
|
675
|
+
const row = toRow(r.row);
|
|
676
|
+
row.$action = r.action;
|
|
677
|
+
if (r.depth !== null)
|
|
678
|
+
row.$depth = r.depth;
|
|
679
|
+
return row;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
/** Превью совпадает с показанным: те же строки по id, с теми же rev и $action (порядок не важен). */
|
|
683
|
+
function samePreview(now, expect) {
|
|
684
|
+
if (now.length !== expect.length)
|
|
685
|
+
return false;
|
|
686
|
+
const want = new Map(expect.map((x) => [x.id.toLowerCase(), x]));
|
|
687
|
+
return now.every((r) => {
|
|
688
|
+
const x = want.get(r.id.toLowerCase());
|
|
689
|
+
return x !== undefined && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
511
692
|
async function deleteOp(w, steps, opStep) {
|
|
512
693
|
const op = opStep.op;
|
|
513
694
|
const S = quoteId(w.schema);
|
|
514
695
|
const ids = await targetIds(w, steps, opStep);
|
|
696
|
+
if (!op.confirm)
|
|
697
|
+
return ids.length ? previewOf(w, ids) : [];
|
|
698
|
+
// delete({ confirm: true, expect }): удалить ровно показанное (план 2.1, A2) — превью заново в той же транзакции.
|
|
699
|
+
// Атомарно (второй раунд 2.1, A1): всё замыкание каскада (строки превью с $action 'delete') блокируется for update в
|
|
700
|
+
// порядке id, и превью строится ещё раз уже под блокировкой. Вставка связи берёт на своих концах for key share
|
|
701
|
+
// (60-write.sql, targets_bad): с for update она конфликтует, с for no key update — нет. Вставщик успел первым —
|
|
702
|
+
// пересчёт видит его строку (preview_changed); мы первые — он получает end_target_missing. Взаимоблокировку при росте
|
|
703
|
+
// замыкания повторяет runTx (40P01)
|
|
704
|
+
if (op.expect !== undefined) {
|
|
705
|
+
let now = ids.length ? await previewOf(w, ids) : [];
|
|
706
|
+
const closure = now.filter((r) => r.$action === 'delete').map((r) => r.id);
|
|
707
|
+
if (closure.length) {
|
|
708
|
+
await lockIds(w.q, S, closure, 'update');
|
|
709
|
+
now = await previewOf(w, ids);
|
|
710
|
+
}
|
|
711
|
+
if (!samePreview(now, op.expect))
|
|
712
|
+
throw lerr('write.preview_changed', undefined, { detail: { preview: now } });
|
|
713
|
+
}
|
|
515
714
|
if (!ids.length)
|
|
516
715
|
return [];
|
|
517
|
-
if (!op.confirm) {
|
|
518
|
-
// превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
|
|
519
|
-
const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
|
|
520
|
-
+ `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
|
|
521
|
-
return res.map((r) => {
|
|
522
|
-
const row = toRow(r.row);
|
|
523
|
-
row.$action = r.action;
|
|
524
|
-
if (r.depth !== null)
|
|
525
|
-
row.$depth = r.depth;
|
|
526
|
-
return row;
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
716
|
// результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
|
|
530
717
|
const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
|
|
531
718
|
const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
|
|
@@ -548,25 +735,155 @@ async function deleteOp(w, steps, opStep) {
|
|
|
548
735
|
}
|
|
549
736
|
return out;
|
|
550
737
|
}
|
|
551
|
-
/**
|
|
552
|
-
async function
|
|
553
|
-
|
|
738
|
+
/** Причины отказа для строк шага restore (живых или надгробий не той версии): одним запросом к журналу. */
|
|
739
|
+
async function refusals(w, rows, want) {
|
|
740
|
+
if (!rows.length)
|
|
741
|
+
return [];
|
|
742
|
+
const S = quoteId(w.schema);
|
|
743
|
+
const exp = rows.map((r) => want?.get(r.id.toLowerCase()) ?? null);
|
|
744
|
+
const log = await query(w, `select x.id::text as id, `
|
|
745
|
+
+ `(select l.op from ${S}.log l where l.id = x.id and l.rev > x.exp order by l.rev limit 1) as after, `
|
|
746
|
+
+ `(select l.op from ${S}.log l where l.id = x.id and l.rev > (select max(t.rev) from ${S}.log t where t.id = x.id and t.op = 'delete') `
|
|
747
|
+
+ `order by l.rev limit 1) as after_last from unnest($1::uuid[], $2::int[]) as x(id, exp)`, [rows.map((r) => r.id), exp]);
|
|
748
|
+
const by = new Map(log.map((r) => [r.id.toLowerCase(), r]));
|
|
749
|
+
return rows.map((r, i) => {
|
|
750
|
+
const l = by.get(r.id.toLowerCase());
|
|
751
|
+
const live = r.$deleted !== true;
|
|
752
|
+
const last = l?.after_last ?? null;
|
|
753
|
+
const why = live && last === 'restore' ? 'restored' : live && last === 'create' ? 'reissued' : 'changed';
|
|
754
|
+
return { id: r.id, rev: r.rev, expected: exp[i], after: l?.after ?? null, deleted: !live, live, why };
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Отказ restore по смыслу (второй раунд 2.1, A2): есть `changed` — restore_stale (conflict), иначе есть `reissued` —
|
|
759
|
+
* restore_id_taken, иначе restore_already (оба — not_deleted). detail.ids — цели этого ключа; с expect (и всегда у
|
|
760
|
+
* restore_stale) detail.stale — все цели, которые не восстановить, с причиной.
|
|
761
|
+
*/
|
|
762
|
+
function restoreRefusal(verb, list, withExpect, cause) {
|
|
763
|
+
const of = (why) => list.filter((x) => x.why === why).map((x) => x.id);
|
|
764
|
+
const extra = (detail) => ({ detail, ...(cause !== undefined ? { cause } : {}) });
|
|
765
|
+
const stale = withExpect ? { stale: list } : {};
|
|
766
|
+
const changed = of('changed');
|
|
767
|
+
if (changed.length)
|
|
768
|
+
return lerr('write.restore_stale', { ids: changed.join(', ') }, extra({ ids: changed, stale: list }));
|
|
769
|
+
const taken = of('reissued');
|
|
770
|
+
if (taken.length)
|
|
771
|
+
return lerr('write.restore_id_taken', { verb, ids: taken.join(', ') }, extra({ ids: taken, ...stale }));
|
|
772
|
+
const done = of('restored');
|
|
773
|
+
return lerr('write.restore_already', { verb, ids: done.join(', ') }, extra({ ids: done, ...stale }));
|
|
774
|
+
}
|
|
775
|
+
/** Строка ответа restore({ expect, partial: true }) для цели, которую не восстановить (второй раунд 2.1, A3). */
|
|
776
|
+
const skipRow = (r, why) => ({ ...r, $action: 'skip', $why: why });
|
|
777
|
+
const byIdOrder = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
778
|
+
/**
|
|
779
|
+
* Удалённые объекты шага (restore). Шаг цели читается с надгробиями; у шагов до него withDeleted() — свой (план 2.1, A9).
|
|
780
|
+
* Без expect: восстанавливаются удалённые цели; если удалённых нет, а живые есть — отказ по смыслу (restoreRefusal: уже
|
|
781
|
+
* восстановлен, id занят новым объектом) или not_deleted (не удалялся). С expect — только цели из expect, и последняя
|
|
782
|
+
* версия каждой — то же надгробие; иначе отказ по смыслу и ничего не восстановлено, а с partial — строка-пропуск.
|
|
783
|
+
*/
|
|
784
|
+
async function deletedTargets(w, steps, opStep, verb, expect, partial) {
|
|
785
|
+
const path = steps.map(bare);
|
|
786
|
+
path[path.length - 1] = { ...path[path.length - 1], withDeleted: true };
|
|
787
|
+
const rows = await readRows(w, path, opStep.opMods ?? {});
|
|
788
|
+
// без явного порядка — по id: restore пишет журнал по очереди целей
|
|
789
|
+
const ordered = (xs) => (opStep.opMods?.order ? xs : [...xs].sort(byIdOrder));
|
|
790
|
+
if (expect !== undefined) {
|
|
791
|
+
const want = new Map(expect.map((x) => [x.id.toLowerCase(), x.rev]));
|
|
792
|
+
const mine = rows.filter((r) => want.has(r.id.toLowerCase()));
|
|
793
|
+
const good = (r) => r.$deleted === true && r.rev === want.get(r.id.toLowerCase());
|
|
794
|
+
const bad = await refusals(w, mine.filter((r) => !good(r)), want);
|
|
795
|
+
if (bad.length && !partial)
|
|
796
|
+
throw restoreRefusal(verb, bad, true);
|
|
797
|
+
const why = new Map(bad.map((x) => [x.id, x.why]));
|
|
798
|
+
const all = ordered(mine);
|
|
799
|
+
return { dead: all.filter(good), skip: all.filter((r) => !good(r)).map((r) => skipRow(r, why.get(r.id) ?? 'changed')), order: all.map((r) => r.id) };
|
|
800
|
+
}
|
|
554
801
|
const dead = rows.filter((r) => r.$deleted);
|
|
555
|
-
if (!dead.length && rows.length)
|
|
802
|
+
if (!dead.length && rows.length) {
|
|
803
|
+
const live = await refusals(w, rows, null);
|
|
804
|
+
if (live.some((x) => x.why !== 'changed'))
|
|
805
|
+
throw restoreRefusal(verb, live.filter((x) => x.why !== 'changed'), false);
|
|
556
806
|
throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
|
|
557
|
-
|
|
558
|
-
|
|
807
|
+
}
|
|
808
|
+
const all = ordered(dead);
|
|
809
|
+
return { dead: all, skip: [], order: all.map((r) => r.id) };
|
|
810
|
+
}
|
|
811
|
+
/** Отказ функции базы restore() по смыслу (уже восстановлен, id занят, версия не та) — причина и подробности; иначе null. */
|
|
812
|
+
function dbRefusal(e) {
|
|
813
|
+
const le = fromDbError(e);
|
|
814
|
+
if (!(le instanceof LetopisError))
|
|
815
|
+
return null;
|
|
816
|
+
const why = le.key === 'sql-time.restore_already' ? 'restored' : le.key === 'sql-time.restore_id_taken' ? 'reissued'
|
|
817
|
+
: le.key === 'sql-time.restore_stale' ? 'changed' : undefined;
|
|
818
|
+
if (!why)
|
|
819
|
+
return null;
|
|
820
|
+
const d = (le.detail ?? {});
|
|
821
|
+
const p = le.params ?? {};
|
|
822
|
+
return {
|
|
823
|
+
id: String(d.id ?? p.id ?? ''), rev: Number(d.rev ?? p.rev ?? 0), expected: d.expected ?? (typeof p.expected === 'number' ? p.expected : null),
|
|
824
|
+
after: d.after ?? null, deleted: d.deleted ?? false, live: d.live ?? why !== 'changed', why,
|
|
825
|
+
};
|
|
559
826
|
}
|
|
560
|
-
/**
|
|
827
|
+
/**
|
|
828
|
+
* Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. С expect
|
|
829
|
+
* база ещё раз сверяет номер последней версии под блокировкой записи журнала (restore(id, rev), план 2.1, A1): гонку
|
|
830
|
+
* между чтением и восстановлением ловит она, и её отказ — по смыслу, как у проверки до записи (второй раунд 2.1, A2).
|
|
831
|
+
* partial (A3): цели, которые не восстановить, — строки-пропуски { …строка, $action: 'skip', $why }, остальные
|
|
832
|
+
* восстанавливаются; каждая цель — под своей точкой сохранения, проигранная гонка тоже становится пропуском.
|
|
833
|
+
*/
|
|
561
834
|
async function restoreOp(w, steps, opStep) {
|
|
562
835
|
const S = quoteId(w.schema);
|
|
563
|
-
const
|
|
836
|
+
const { expect, partial } = opStep.op;
|
|
837
|
+
const { dead, skip, order } = await deletedTargets(w, steps, opStep, 'restore', expect, partial);
|
|
564
838
|
if (!dead.length)
|
|
565
|
-
return
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
839
|
+
return inTargetOrder(order, skip);
|
|
840
|
+
let ids = dead.map((r) => r.id);
|
|
841
|
+
if (expect === undefined) {
|
|
842
|
+
// прежний вызов restore(id): работает и со схемой прежней ревизии
|
|
843
|
+
try {
|
|
844
|
+
await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
const r = dbRefusal(e);
|
|
848
|
+
if (r && r.why !== 'changed')
|
|
849
|
+
throw restoreRefusal('restore', [r], false, e);
|
|
850
|
+
throw e;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
else if (!partial) {
|
|
854
|
+
try {
|
|
855
|
+
await query(w, `select ${S}.restore(u.x, u.r) from unnest($1::uuid[], $2::int[]) with ordinality as u(x, r, i) order by u.i`, [ids, dead.map((r) => r.rev)]);
|
|
856
|
+
}
|
|
857
|
+
catch (e) {
|
|
858
|
+
const r = dbRefusal(e);
|
|
859
|
+
if (r)
|
|
860
|
+
throw restoreRefusal('restore', [r], true, e);
|
|
861
|
+
throw e;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
const lost = new Set();
|
|
866
|
+
for (const r of dead) {
|
|
867
|
+
await w.q.unsafe('savepoint letopis_restore');
|
|
868
|
+
try {
|
|
869
|
+
await query(w, `select ${S}.restore($1::uuid, $2::int)`, [r.id, r.rev]);
|
|
870
|
+
await w.q.unsafe('release savepoint letopis_restore');
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
await w.q.unsafe('rollback to savepoint letopis_restore');
|
|
874
|
+
const why = dbRefusal(e)?.why;
|
|
875
|
+
if (!why)
|
|
876
|
+
throw e;
|
|
877
|
+
lost.add(r.id);
|
|
878
|
+
skip.push(skipRow(r, why));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
ids = ids.filter((id) => !lost.has(id));
|
|
882
|
+
}
|
|
883
|
+
const res = ids.length
|
|
884
|
+
? await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids])
|
|
885
|
+
: [];
|
|
886
|
+
return inTargetOrder(order, [...res.map((r) => toRow(r.row)), ...skip]);
|
|
570
887
|
}
|
|
571
888
|
async function opCall(w, steps, opStep) {
|
|
572
889
|
await useReason(w, reasonFor(w, opStep));
|
|
@@ -632,10 +949,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
632
949
|
else {
|
|
633
950
|
last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
|
|
634
951
|
}
|
|
635
|
-
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
|
|
952
|
+
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после restore — от
|
|
953
|
+
// восстановленных, без строк-пропусков partial
|
|
636
954
|
start = kind === 'delete' && opStep.op.confirm
|
|
637
955
|
? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
|
|
638
|
-
: last;
|
|
956
|
+
: kind === 'restore' ? last.filter((r) => r.$action !== 'skip') : last;
|
|
639
957
|
prevOp = opStep;
|
|
640
958
|
}
|
|
641
959
|
// причина глагола не переходит на следующие операторы транзакции
|
package/llms.txt
CHANGED
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
# letopis
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> ИИ-агенту: код на letopis пишите по [AGENT-CHEATSHEET.md](AGENT-CHEATSHEET.md) — памятка лежит в пакете рядом с этим файлом (`node_modules/letopis/AGENT-CHEATSHEET.md`); прочитайте её целиком: «Правила для любой задачи», рабочий цикл и «Грабли».
|
|
4
|
+
>
|
|
5
|
+
> Хранилище версионированных объектов для Node.js на PostgreSQL 18, версия 3.0. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Team(g).Item().rows()`.
|
|
6
|
+
|
|
7
|
+
Безопасность по умолчанию (одна строка — одно правило):
|
|
8
|
+
|
|
9
|
+
- Удаление — только по превью: `delete()` → показать → `delete({ confirm: true, expect: превью })`; `delete({ confirm: true })` без `expect` — `invalid_query` (`write.confirm_without_expect`).
|
|
10
|
+
- Восстановление — только по надгробиям: `restore({ expect: ответ delete() })`; `restore()` без `expect`, и по явному id тоже, — `invalid_query` (`write.restore_without_expect`).
|
|
11
|
+
- Массовое и без сверки — только явно и только в осознанных скриптах: `{ all: true }` у `update`, `delete({ confirm: true, all: true })`, `restore({ all: true })`.
|
|
12
|
+
- Арендатор — только из серверной сессии (`svc.auth.session(token)`), никогда из параметра клиента.
|
|
13
|
+
- Права по умолчанию запрещают; подключение в обход RLS — `bypass_rls`, пока нет `allowBypassRls: true`.
|
|
14
|
+
- `createTenant(admin, …)` — `admin` только пул `postgres()`, иначе `invalid_query` (`api.create_tenant_admin`).
|
|
15
|
+
- Журнал событий маскирует значения полей с паролями, токенами, ключами, секретами и кредами (и полей из `redact`), учётные данные в строке подключения и пароль в тексте SQL; значения параметров SQL, тексты ошибок PostgreSQL и поле `data` строк в подробностях ошибок не пишет. Значения фильтров чтения в `chain` пишутся как есть — персональные поля добавьте в `redact`.
|
|
4
16
|
|
|
5
17
|
Главное для кода на letopis:
|
|
6
18
|
|
|
7
|
-
-
|
|
8
|
-
- Схему ставит `up({ dsn, schema })` от
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
19
|
+
- Установка: `npm pkg set type=module && npm i letopis zod postgres@3 && npm i -D tsx` (код приложения — ESM; `postgres@3` — та же мажорная версия, что у letopis, иначе `createTenant` не проходит проверку типов). Node 22+, PostgreSQL 18+ с pgcrypto, база UTF8. Zod 4 нужен только `letopis/model`, `@modelcontextprotocol/server` — только `letopis/mcp`.
|
|
20
|
+
- Схему ставит `up({ dsn, schema, login, tenant })` от администратора (суперпользователь или роль с `CREATEROLE`, `CREATE` в базе и `CREATEDB`, если базы нет); роль приложения заводит `login`, её строка подключения — `r.login.dsn`. Ключ сервиса выдаёт только первый `up()` — храните его сами или передавайте свой `up({ serviceKey })`; `connect({ apiKey: null | '' })` — сразу `invalid_data` (`api.connect_empty_credential`), а `undefined` — подключение без сессии, поэтому ключ проверяйте до `connect()`; `up({ upgrade: true })` обновляет схему отставшей ревизии, `up({ fresh: true })` пересоздаёт её с потерей данных.
|
|
21
|
+
- Вход пользователя: одно подключение сервиса на процесс (`connect({ apiKey })`), `svc.auth.verifyPassword()` → `{ account, token }`, затем `svc.as(account, { tenant })` на запрос. Без `{ tenant }` сессия — в **личном** арендаторе пользователя (классов там нет, `invalid_class`); проверка — `db.tenant`. По погашенной сессии чтение отдаёт пустые ответы, запись — `no_session`, поэтому `svc.auth.session(token)` — в начале каждого запроса.
|
|
22
|
+
- Классы — хабы (объекты) и связи (отношения с концами): `hub()` и `link()` из `letopis/model`, в базу — `syncModels()` или `letopis sync` (в арендатора сессии). Имя роли конца одно на арендатора; роли `Group`, `Object`, `User`, `Tenant` заняты системными классами `rule` и `member` — конец с таким именем на другой класс — `invalid_class` (`model.end_role_system`).
|
|
23
|
+
- Уникальность и инварианты держит ключ, а не проверка в коде: у класса с ключом id — UUID v5 (`db.idOf`), второй такой `create()` — `exists`, и под гонкой. «Одна открытая запись на объект» — связь с ключом по концу (`key: ['Obj']`); `restore()` бизнес-правил не проверяет, поэтому и при возврате инвариант держит только ключ.
|
|
24
|
+
- Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт, `update` сливает патч и никогда не создаёт (`undefined` в патче — поле не меняется), `upsert` заменяет данные целиком (поле с `undefined` пропадает; только у классов с ключом).
|
|
25
|
+
- Удаление ровно показанного атомарно: превью сверяется под блокировкой каскада, расхождение — `conflict` (`write.preview_changed`) с новым превью в `err.detail.preview`. Целей нет: непустой `expect` — `conflict` с `err.detail.preview: []` («уже удалено или не найдено»), `expect: []` — пустой ответ.
|
|
26
|
+
- Возврат — по ответу `delete()` (или надгробиям `withDeleted()` по id): `restore({ expect })` — всё или отказ по смыслу (`write.restore_already`, `write.restore_id_taken` — `not_deleted`; `write.restore_stale` — `conflict`); `restore({ expect, partial: true })` — что можно, пропуски — строки `$action: 'skip'` с `$why`. Цель конца — первой, каскад — следующим шагом, оба — в одной `db.begin()` с `commit()`/`rollback()`.
|
|
27
|
+
- Гонка за прочитанное — `forUpdate()` в `db.begin()` (только с `rows`, `first`, `ids`), `update(patch, { rev })` (`conflict`) или ключ; счётчик — `update({ n: inc(k) })`. Фильтр `update()` по данным, концам, `tags()`, `owner()` перепроверяется под блокировкой строки: выведенную параллельной транзакцией из-под фильтра строку он не правит (из двух одновременных правок со `status: 'new'` проходит одна); фильтр только по id — пройдут обе. Форма транзакции — `const tr = await db.begin(); try { …; await tr.commit() } catch (e) { await tr.rollback(); throw e }`; `db.begin(fn)` — `invalid_query`.
|
|
28
|
+
- Строгий режим: `connect({ models })` или `connect<Models>()` дают строгие типы; неизвестное поле, класс или опция — отказ с похожими именами; `update()` без фильтра — `invalid_query` (все строки — `{ all: true }`); id не uuid — `invalid_query` до запроса; фильтр `undefined` — шаг без фильтра и предупреждение.
|
|
29
|
+
- Концы связи — не поля данных: из пути или слотом `.Роль.set(id)`; имя конца в данных — `invalid_data` (`write.link_in_data`).
|
|
30
|
+
- Модификаторы выборки (`sort`, `limit`, `offset`, `after`, `forUpdate`) — последнему шагу; `withDeleted()` — своему шагу. `versions()` — терминал; по пути — только живые, с удалёнными — `withDeleted()` на шаге или явный id.
|
|
31
|
+
- Ответы и ошибки: строка — `{ id, class, rev, links, data, … }`, превью — с `$action`, надгробие — `$deleted: true`; ошибка — `LetopisError` с `err.code`, `err.key`, `err.detail`, `err.hint`, у данных — `ValidationError` с `err.issues: [{ path, keyword, message }]`. Штатные отказы (`exists`, `conflict`, `not_deleted`, `target_not_found`, `tenant_denied`, `acl_denied`, `no_session`, …) — ответ человеку своими фразами по `err.key`/`err.code`; ошибки кода — 500.
|
|
32
|
+
- Логирование: `connect({ log, logLevel, redact })` и `up({ … })` — функция или объект-логгер (`console`, pino), порог `debug|info|warn|error|off` (по умолчанию `info`); без кода — `LETOPIS_LOG=info` (JSON-строки в stderr), CLI — `--log[=уровень]`. События `delete` и `restore` — что удалено и что не вернулось; внутри `db.begin()` событие значит «выполнено в транзакции», фиксацию доказывает `tx.commit` с тем же `tx`. Штатные отказы — событие `error` уровня `warn`, ошибки кода — уровня `error`.
|
|
33
|
+
- Обслуживание — `maintain()` (администраторское подключение, системный администратор или право `letopis.maintain`, иначе `acl_denied`); загрузка — `db.load(rows)` с администраторского подключения; подписка — `db.watch({ from, classes })` и `await sub.ready()` перед записью.
|
|
34
|
+
- MCP: `createMcpServer(await svc.as(пользователь, { tenant }))` из `letopis/mcp` и `letopis mcp` дают ИИ-агенту `letopis_model`, `letopis_query`, `letopis_history` и, с `write: true`, `letopis_write`; удаление подтверждает человек, удаляется ровно показанное превью.
|
|
35
|
+
- Лицензия — PolyForm Noncommercial 1.0.0 (SPDX `PolyForm-Noncommercial-1.0.0`): исходный код доступен, некоммерческое использование бесплатно, коммерческое — только по отдельной лицензии владельца (запрос — issue на https://github.com/alepri51/letopis/issues).
|
|
20
36
|
|
|
21
37
|
## Документы
|
|
22
38
|
|
|
23
|
-
- [
|
|
39
|
+
- [Памятка для агента](AGENT-CHEATSHEET.md): общие правила для любой задачи на letopis, рабочий цикл одной программой и грабли
|
|
24
40
|
- [Руководство](README.md): руководство по задачам, справочник API, ограничения
|
|
25
|
-
- [
|
|
41
|
+
- [Безопасность по умолчанию](README.md#безопасность-по-умолчанию): что отклоняется без сверки и как это сделать осознанно
|
|
42
|
+
- [Логирование](README.md#логирование): опции, уровни, `LETOPIS_LOG`, формат события, маскировка, список событий
|
|
43
|
+
- [Ошибки](README.md#15-ошибки): все коды и ключи — что значит, что делать и что сказать человеку
|
|
44
|
+
- Машинный каталог API и ошибок — `api-contract.json` в пакете (`node_modules/letopis/api-contract.json`)
|
|
26
45
|
|
|
27
46
|
## Дополнительно
|
|
28
47
|
|
|
29
|
-
- [Изменения по версиям](CHANGELOG.md):
|
|
48
|
+
- [Изменения по версиям](CHANGELOG.md): что изменилось при переходе на 3.0 и чем заменить снятое
|
|
49
|
+
- [Лицензия](LICENSE) и [коммерческое использование](LICENSE-COMMERCIAL.md): что можно без разрешения и как получить коммерческую лицензию
|
|
30
50
|
- Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы
|