letopis 2.0.0 → 3.0.1

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 (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/dist/chain.js CHANGED
@@ -15,7 +15,9 @@
15
15
  import { lerr, lwarn, similar } from './errors.js';
16
16
  import { sessionTag } from './cache.js';
17
17
  import { buildRead, nextNodeKey, resolveHop, roleTargets, stepClasses, toRow, } from './sql.js';
18
- import { execBatch, execPlan, precheckPlan, slotRole } from './write.js';
18
+ import { CHAIN_METHODS, OP } from './types.js';
19
+ import { NOLOG, guarded, traced } from './log.js';
20
+ import { completeIssues, dropUndefined, execBatch, execPlan, lockIds, precheckPlan, slotRole, } from './write.js';
19
21
  /** Внутренний канал: план чужой ленивой цепочки (entity()). */
20
22
  export const PLAN = Symbol('letopis.plan');
21
23
  const peekPlan = (x) => x !== null && (typeof x === 'object' || typeof x === 'function') ? x[PLAN] : undefined;
@@ -38,12 +40,75 @@ export function lookupStep(reg, name) {
38
40
  const t = targets.map((n) => reg.find(n)).find((c) => !!c);
39
41
  return t ? { cls: t, role: name } : undefined;
40
42
  }
41
- /** Имена, которыми начинается шаг: классы, их алиасы и роли концов реестра. */
42
- const stepNames = (reg) => reg.all().flatMap((c) => [c.name, ...(c.alias ? [c.alias] : []), ...Object.keys(c.ends)]);
43
- /** Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). */
44
- const unknownName = (reg, name) => {
45
- throw lerr('api.unknown_step', { name, similar: similar(name, stepNames(reg)) });
43
+ /** Системные классы (прав, членств, аккаунтов, описаний): в «похожие» неизвестного шага не идут (план 2.1, A6). */
44
+ const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
45
+ const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
46
+ const isSystem = (c) => c.ancestors.some((n) => SYSTEM_CLASSES.has(n));
47
+ /** Имена, которыми начинается шаг: классы, их алиасы и роли концов реестра, кроме системных классов. */
48
+ const stepNames = (reg) => reg.all().filter((c) => !isSystem(c)).flatMap((c) => [c.name, ...(c.alias ? [c.alias] : []), ...Object.keys(c.ends)]);
49
+ /**
50
+ * Свойства массива, которые спрашивают у цепочки, забыв терминал: `.length`, `.map(…)`, `[0]` — не шаги (план 2.1, A6).
51
+ * Не case-метки member: имена попали бы в зарезервированные (check:docs, reserved) — проверяются после неудачного поиска.
52
+ */
53
+ const ARRAY_PROPS = new Set(['length', 'map', 'filter', 'forEach', 'reduce', 'find', 'some', 'every', 'slice', 'at', 'toJSON', '0']);
54
+ /**
55
+ * Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). Свойство массива — api.chain_not_rows.
56
+ * В реестре только классы System (3.0.1): сессия в личном арендаторе (арендатор = аккаунт сессии) —
57
+ * api.unknown_step_no_classes, иначе в арендаторе просто нет классов — api.unknown_step_empty_tenant.
58
+ */
59
+ const unknownName = (ctx, name) => {
60
+ const reg = ctx.reg;
61
+ if (ARRAY_PROPS.has(name))
62
+ throw lerr('api.chain_not_rows', { name });
63
+ let key = 'api.unknown_step';
64
+ if (reg.all().every((c) => c.tenant === SYSTEM_ID)) {
65
+ const tenant = ctx.tenant();
66
+ const account = ctx.account?.() ?? null;
67
+ key = tenant !== null && account !== null && tenant.toLowerCase() === account.toLowerCase()
68
+ ? 'api.unknown_step_no_classes' : 'api.unknown_step_empty_tenant';
69
+ }
70
+ throw lerr(key, { name, similar: similar(name, stepNames(reg)) });
71
+ };
72
+ /** Терминалы и агрегаты цепочки: у цепочки без сессии они отдают отказ промисом (sessionless). */
73
+ const TERMINALS = new Set(['rows', 'first', 'ids', 'count', 'paths', 'versions', 'sum', 'avg', 'min', 'max', 'countBy']);
74
+ /**
75
+ * Неизвестный шаг у фасада без сессии (токен погашен или истёк после connect(), второй раунд 2.1, A5): не синхронный
76
+ * выброс про «личный арендатор», а цепочка, чьи терминалы отдают api.token_not_active (no_session) отклонённым
77
+ * промисом — как у любой другой ошибки запроса. Шаги, модификаторы и глаголы такой цепочки ничего не проверяют.
78
+ */
79
+ function sessionless() {
80
+ const fail = () => Promise.reject(lerr('api.token_not_active'));
81
+ const dead = new Proxy(function () { }, {
82
+ get(_t, p) {
83
+ if (typeof p === 'symbol' || p === 'then')
84
+ return undefined;
85
+ return TERMINALS.has(p) ? fail : dead;
86
+ },
87
+ apply: () => dead,
88
+ });
89
+ return dead;
90
+ }
91
+ /**
92
+ * Фасад с токеном без сессии: шага нет среди классов, а арендатора у сессии нет — шаг из sessionless(); батч и
93
+ * подключение без токена (администраторское) — как прежде.
94
+ */
95
+ const noSession = (ctx, name) => !ctx.batch && !ARRAY_PROPS.has(name) && ctx.hasToken?.() === true && ctx.tenant() === null;
96
+ /** Перебор цепочки (for…of, spread, Array.from) — понятный отказ вместо «is not iterable». */
97
+ const notIterable = () => {
98
+ throw lerr('api.chain_not_rows', { name: '[Symbol.iterator]' });
46
99
  };
100
+ /**
101
+ * withDeleted() — модификатор своего шага (план 2.1, A9): у шага не последнего, когда у последнего его нет, — один раз
102
+ * предупреждение (до 2.1 флаг относился к последнему шагу).
103
+ */
104
+ function warnWithDeleted(steps, log) {
105
+ const last = steps[steps.length - 1];
106
+ if (!last || last.withDeleted)
107
+ return;
108
+ const at = steps.slice(0, -1).find((s) => s.withDeleted && s.pivotKey === undefined);
109
+ if (at)
110
+ lwarn('api.with_deleted_step', { step: at.name, last: last.name }, { once: `with-deleted|${at.name}|${last.name}`, log });
111
+ }
47
112
  const nodeOf = (steps, s) => s.pivotKey === undefined ? s : steps.find((x) => x.nodeKey === s.pivotKey) ?? s;
48
113
  /** Узел для возврата: последний реальный шаг того же класса-связи в текущем читающем сегменте. */
49
114
  function pivotNodeOf(steps, cls) {
@@ -57,7 +122,7 @@ function pivotNodeOf(steps, cls) {
57
122
  return undefined;
58
123
  }
59
124
  const noHistoryWarned = new Set();
60
- function warnNoHistory(steps, method) {
125
+ function warnNoHistory(steps, method, log) {
61
126
  const cls = steps[steps.length - 1]?.cls;
62
127
  if (!cls || cls.history !== false)
63
128
  return;
@@ -65,7 +130,7 @@ function warnNoHistory(steps, method) {
65
130
  if (noHistoryWarned.has(key))
66
131
  return;
67
132
  noHistoryWarned.add(key);
68
- lwarn('api.no_history', { method, cls: cls.name });
133
+ lwarn('api.no_history', { method, cls: cls.name }, { log });
69
134
  }
70
135
  function report(ctx, mode, steps, t0, rows) {
71
136
  if (!ctx.onQuery && ctx.slowMs === undefined)
@@ -76,7 +141,235 @@ function report(ctx, mode, steps, t0, rows) {
76
141
  if (ctx.onQuery)
77
142
  ctx.onQuery({ mode, classes, ms, rows, slow });
78
143
  else if (slow)
79
- lwarn('api.slow_query', { ms: ms.toFixed(0), mode, classes: classes.join('→') });
144
+ lwarn('api.slow_query', { ms: ms.toFixed(0), mode, classes: classes.join('→') }, { log: ctx.log });
145
+ }
146
+ /**
147
+ * Свойства цепочки, вызов которых может отказать синхронно (модификаторы и глаголы): ошибка — событие error. Не
148
+ * case-метки: check-docs принимает их за зарезервированные имена.
149
+ */
150
+ const GUARDED = new Set(['entity', 'limit', 'offset', 'sort', 'asOf', 'after', 'withDeleted', 'deep', 'exact', 'alias', 'tags',
151
+ 'owner', 'create', 'update', 'upsert', 'delete', 'forUpdate', 'restore', 'cache']);
152
+ const LIST_MAX = 50;
153
+ /** Сколько строк каждого класса. */
154
+ function byClass(rows) {
155
+ const m = {};
156
+ for (const r of rows)
157
+ m[r.class] = (m[r.class] ?? 0) + 1;
158
+ return m;
159
+ }
160
+ /** id строк — не больше LIST_MAX. */
161
+ const idsOf = (rows) => rows.slice(0, LIST_MAX).map((r) => r.id);
162
+ /** Значение фильтра или аргумента для текста цепочки: поля-секреты — '***', длинное — обрезано. */
163
+ function fmtVal(log, x, depth = 0) {
164
+ if (x === undefined)
165
+ return 'undefined';
166
+ if (x === null || typeof x === 'number' || typeof x === 'boolean')
167
+ return String(x);
168
+ if (typeof x === 'string')
169
+ return JSON.stringify(x.length > 80 ? `${x.slice(0, 80)}…` : x);
170
+ if (typeof x === 'bigint')
171
+ return `${x}n`;
172
+ if (typeof x !== 'object')
173
+ return typeof x;
174
+ if (depth > 3)
175
+ return '…';
176
+ if (x instanceof Date)
177
+ return JSON.stringify(x.toISOString());
178
+ const op = x[OP];
179
+ if (typeof op === 'string') {
180
+ const args = x.args ?? [];
181
+ return `${op}(${args.map((a) => fmtVal(log, a, depth + 1)).join(', ')})`;
182
+ }
183
+ const peek = peekPlan(x);
184
+ if (peek)
185
+ return `(${chainText(log, peek.steps, peek.mods)})`;
186
+ if (Array.isArray(x)) {
187
+ const head = x.slice(0, 10).map((a) => fmtVal(log, a, depth + 1)).join(',');
188
+ return `[${head}${x.length > 10 ? `,…+${x.length - 10}` : ''}]`;
189
+ }
190
+ const entries = Object.entries(x);
191
+ const shown = entries.slice(0, 20).map(([k, v]) => `${JSON.stringify(k)}:${log.secret(k) ? '"***"' : fmtVal(log, v, depth + 1)}`);
192
+ return `{${shown.join(',')}${entries.length > 20 ? `,…+${entries.length - 20}` : ''}}`;
193
+ }
194
+ /** Данные записи — только имена полей (значения в лог не идут). */
195
+ const keysOf = (data) => `{${Object.keys(data ?? {}).join(',')}}`;
196
+ /** Модификаторы выборки текстом: .sort(…).limit(…)… */
197
+ function modsText(m) {
198
+ if (!m)
199
+ return '';
200
+ let t = '';
201
+ if (m.order !== undefined)
202
+ t += `.sort(${JSON.stringify(m.order)}${m.desc ? ", 'desc'" : ''})`;
203
+ if (m.limit !== undefined)
204
+ t += `.limit(${m.limit})`;
205
+ if (m.offset !== undefined)
206
+ t += `.offset(${m.offset})`;
207
+ if (m.asOf !== undefined)
208
+ t += `.asOf(${JSON.stringify(m.asOf)})`;
209
+ if (m.after !== undefined)
210
+ t += '.after(…)';
211
+ if (m.forUpdate)
212
+ t += '.forUpdate()';
213
+ if (m.cache)
214
+ t += `.cache(${m.cache.ttl !== undefined ? `{ttl:${m.cache.ttl}}` : ''})`;
215
+ return t;
216
+ }
217
+ /** Глагол текстом: данные — именами полей, expect — числом строк. */
218
+ function opText(op) {
219
+ const o = [];
220
+ if (op.kind === 'create' || op.kind === 'upsert')
221
+ return `${op.kind}(${keysOf(op.data)})`;
222
+ if (op.kind === 'update') {
223
+ if (op.rev !== undefined)
224
+ o.push(`rev:${op.rev}`);
225
+ if (op.all)
226
+ o.push('all:true');
227
+ return `update(${keysOf(op.data)}${o.length ? `, {${o.join(',')}}` : ''})`;
228
+ }
229
+ if (op.kind === 'delete') {
230
+ if (op.confirm)
231
+ o.push('confirm:true');
232
+ if (op.all)
233
+ o.push('all:true');
234
+ }
235
+ if (op.kind === 'restore' && op.partial)
236
+ o.push('partial:true');
237
+ if (op.expect !== undefined)
238
+ o.push(`expect:[${op.expect.length}]`);
239
+ return `${op.kind}(${o.length ? `{${o.join(',')}}` : ''})`;
240
+ }
241
+ /** Значение слота текстом. */
242
+ function slotText(v) {
243
+ if (v === null)
244
+ return 'null';
245
+ if (typeof v === 'string')
246
+ return JSON.stringify(v);
247
+ if (Array.isArray(v))
248
+ return `[${v.length}]`;
249
+ return 'plan' in v ? '(цепочка)' : JSON.stringify(v.id);
250
+ }
251
+ /**
252
+ * Цепочка в читаемом виде: `Shop({"name":"A"}).Product.limit(10).rows()`. Фильтры — значениями (поля-секреты — '***'),
253
+ * данные записи — только именами полей.
254
+ */
255
+ export function chainText(log, steps, mods, term, args = []) {
256
+ let out = '';
257
+ steps.forEach((s, i) => {
258
+ if (!s.self) {
259
+ out += `${i ? '.' : ''}${s.name}(${s.filter !== undefined ? fmtVal(log, s.filter) : ''})`;
260
+ if (s.exact)
261
+ out += '.exact()';
262
+ if (s.withDeleted)
263
+ out += '.withDeleted()';
264
+ if (s.deepMax !== undefined)
265
+ out += `.deep(${s.deepMax})`;
266
+ if (s.aliasKey !== undefined)
267
+ out += `.alias(${JSON.stringify(s.aliasKey)})`;
268
+ if (s.tagsFilter !== undefined)
269
+ out += `.tags(${fmtVal(log, s.tagsFilter)})`;
270
+ if (s.ownerFilter !== undefined)
271
+ out += `.owner(${JSON.stringify(s.ownerFilter)})`;
272
+ }
273
+ if (s.op) {
274
+ out += `${modsText(s.opMods)}.${opText(s.op)}`;
275
+ for (const sl of s.slots ?? [])
276
+ out += `.${sl.role}.${sl.op}(${sl.op === 'unset' ? '' : slotText(sl.value)})`;
277
+ }
278
+ if (out.length > 4000)
279
+ return;
280
+ });
281
+ out += modsText(mods);
282
+ if (term)
283
+ out += `.${term}(${args.filter((a) => a !== undefined).map((a) => fmtVal(log, a)).join(', ')})`;
284
+ return out;
285
+ }
286
+ /** Подробности события записи: состав по классам и id; у удаления — сверка с expect, у restore — пропуски. */
287
+ function writeDetail(op, rows) {
288
+ const d = {};
289
+ if (op.kind === 'delete') {
290
+ d.confirm = op.confirm;
291
+ d.expect = op.expect !== undefined ? op.expect.length : false;
292
+ if (op.all)
293
+ d.all = true;
294
+ // с expect замыкание каскада блокируется for update и превью сверяется заново (write.ts, deleteOp)
295
+ if (op.confirm && op.expect !== undefined)
296
+ d.locked = true;
297
+ if (rows && !op.confirm) {
298
+ const actions = {};
299
+ // строки-сводки скрытых правами строк (3.0.1): в actions.hidden — их число, в классы и id сводки не идут
300
+ for (const r of rows)
301
+ if (r.$action)
302
+ actions[r.$action] = (actions[r.$action] ?? 0) + (r.$action === 'hidden' ? r.$count ?? 0 : 1);
303
+ d.actions = actions;
304
+ rows = rows.filter((r) => r.$action !== 'hidden');
305
+ }
306
+ }
307
+ else if (op.kind === 'restore') {
308
+ d.expect = op.expect !== undefined ? op.expect.length : false;
309
+ if (op.partial)
310
+ d.partial = true;
311
+ if (rows) {
312
+ const skipped = rows.filter((r) => r.$action === 'skip');
313
+ d.restored = rows.length - skipped.length;
314
+ d.skipped = skipped.slice(0, LIST_MAX).map((r) => ({ id: r.id, why: r.$why }));
315
+ rows = rows.filter((r) => r.$action !== 'skip');
316
+ }
317
+ }
318
+ else if (rows && op.kind === 'upsert') {
319
+ const how = {};
320
+ for (const r of rows)
321
+ if (r.$upsert)
322
+ how[r.$upsert] = (how[r.$upsert] ?? 0) + 1;
323
+ d.upsert = how;
324
+ }
325
+ if (rows) {
326
+ d.classes = byClass(rows);
327
+ d.ids = idsOf(rows);
328
+ }
329
+ return d;
330
+ }
331
+ /** Событие терминала: read — без глаголов, иначе по последнему глаголу write, delete.preview, delete, restore. */
332
+ function termEvent(log, steps, mods, name, args, st, r, ms) {
333
+ const ops = steps.flatMap((s) => (s.op ? [s.op] : []));
334
+ const rows = name === 'count' && typeof r === 'number' ? r : st.n;
335
+ if (!ops.length) {
336
+ if (!log.wants('read'))
337
+ return;
338
+ log.emit('read', { op: name, ms, ...(rows !== undefined ? { rows } : {}), chain: chainText(log, steps, mods, name, args),
339
+ ...(st.cache ? { detail: { cache: st.cache } } : {}) });
340
+ return;
341
+ }
342
+ const last = ops[ops.length - 1];
343
+ const event = last.kind === 'delete' ? (last.confirm ? 'delete' : 'delete.preview') : last.kind === 'restore' ? 'restore' : 'write';
344
+ // запись в db.begin() — в счётчик транзакции: по нему её tx.commit идёт уровнем info
345
+ log.mark(event);
346
+ if (!log.wants(event))
347
+ return;
348
+ log.emit(event, { op: [...new Set(ops.map((o) => o.kind))].join(','), ms, rows: st.rows ? st.rows.length : rows,
349
+ chain: chainText(log, steps, mods, name, args), detail: writeDetail(last, st.rows) });
350
+ }
351
+ /** Терминал под логгером: время, событие успеха и error с цепочкой; выключенный логгер — сам вызов. */
352
+ async function traceTerm(ctx, steps, mods, name, args, fn) {
353
+ const log = ctx.log;
354
+ if (!log?.enabled)
355
+ return fn({});
356
+ const st = {};
357
+ const t0 = performance.now();
358
+ let r;
359
+ try {
360
+ r = await fn(st);
361
+ }
362
+ catch (e) {
363
+ log.error(e, { op: name, ms: performance.now() - t0, chain: chainText(log, steps, mods, name, args) });
364
+ throw e;
365
+ }
366
+ try {
367
+ termEvent(log, steps, mods, name, args, st, r, performance.now() - t0);
368
+ }
369
+ catch {
370
+ // событие не собралось — терминал всё равно отработал
371
+ }
372
+ return r;
80
373
  }
81
374
  /** Классы, которые читает цепочка (семейства шагов): по ним сбрасывается её ответ в кэше. */
82
375
  function readClasses(reg, steps) {
@@ -94,7 +387,7 @@ function cacheTtl(ctx, steps, mods, classes) {
94
387
  return null;
95
388
  return Math.min(...marks.map((m) => (m.ttl !== undefined ? m.ttl * 1000 : c.store.ttlMs)));
96
389
  }
97
- async function exec(ctx, q, mode, steps, mods = {}) {
390
+ async function exec(ctx, q, mode, steps, mods = {}, st = {}) {
98
391
  const classes = ctx.cache ? readClasses(ctx.reg, steps) : [];
99
392
  const ttl = ctx.cache ? cacheTtl(ctx, steps, mods, classes) : null;
100
393
  if (ttl !== null) {
@@ -102,8 +395,16 @@ async function exec(ctx, q, mode, steps, mods = {}) {
102
395
  const who = ctx.cache.who();
103
396
  const key = store.key(q.text, q.params, who);
104
397
  const hit = store.get(key);
105
- if (hit)
398
+ const log = ctx.log;
399
+ if (hit) {
400
+ st.cache = 'hit';
401
+ if (log?.wants('cache.hit'))
402
+ log.emit('cache.hit', { op: mode, rows: hit.length, chain: chainText(log, steps, mods, mode) });
106
403
  return hit;
404
+ }
405
+ st.cache = 'miss';
406
+ if (log?.wants('cache.miss'))
407
+ log.emit('cache.miss', { op: mode, chain: chainText(log, steps, mods, mode), detail: { ttl: ttl / 1000 } });
107
408
  const tag = sessionTag(who.token);
108
409
  const tok = store.token(classes, tag, who.tenant);
109
410
  const t0 = performance.now();
@@ -122,18 +423,24 @@ async function exec(ctx, q, mode, steps, mods = {}) {
122
423
  report(ctx, mode, steps, t0, res.length);
123
424
  return res;
124
425
  }
125
- async function read(ctx, steps, mods, mode, agg) {
426
+ async function read(ctx, steps, mods, mode, agg, st = {}) {
126
427
  const hasOps = steps.some((s) => s.op);
127
428
  if (ctx.batch && hasOps)
128
429
  throw lerr('api.batch_queued');
430
+ if (!hasOps)
431
+ warnWithDeleted(steps, ctx.log);
432
+ const counted = (r) => {
433
+ st.n = r.res.length;
434
+ return r;
435
+ };
129
436
  if (mods.forUpdate)
130
- return lockedRead(ctx, steps, mods, mode);
437
+ return lockedRead(ctx, steps, mods, mode, agg).then(counted);
131
438
  if (hasOps)
132
- return plan(ctx, steps, mods, mode, agg);
439
+ return plan(ctx, steps, mods, mode, agg, st).then(counted);
133
440
  const { cache: _c, ...plain } = mods;
134
441
  const q = buildRead(ctx.reg, ctx.schema, steps, plain, mode, agg);
135
442
  const ev = mode === 'countPaths' ? 'count' : mode;
136
- return exec(ctx, q, ev, steps, mods).then((res) => ({ q, res }));
443
+ return exec(ctx, q, ev, steps, mods, st).then((res) => counted({ q, res }));
137
444
  }
138
445
  /** Модификатор выборки, заданный в mods, — именем метода цепочки; forUpdate() отказывает сам (lock_unsupported). */
139
446
  function modName(mods) {
@@ -167,8 +474,13 @@ function checkAfterVerb(steps, mods) {
167
474
  * Цепочка с глаголами: весь план одной транзакцией (§2.5). Результат приводится к виду ответа
168
475
  * чтения (строки rows/paths, id, n, агрегаты), чтобы терминалы разбирали его одинаково.
169
476
  */
170
- async function plan(ctx, steps, mods, mode, agg) {
171
- precheckPlan(ctx.reg, steps, mods.order, agg);
477
+ async function plan(ctx, steps, mods, mode, agg, st = {}) {
478
+ try {
479
+ precheckPlan(ctx.reg, steps, mods.order, agg);
480
+ }
481
+ catch (e) {
482
+ throw await completeIssues(ctx, e);
483
+ }
172
484
  const t0 = performance.now();
173
485
  const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, steps, mods, mode, agg));
174
486
  if (r.kind === 'read') {
@@ -182,6 +494,7 @@ async function plan(ctx, steps, mods, mode, agg) {
182
494
  return r;
183
495
  }
184
496
  const rows = r.rows;
497
+ st.rows = rows;
185
498
  report(ctx, mode === 'countPaths' ? 'count' : mode === 'paths' ? 'paths' : mode === 'ids' ? 'ids' : 'rows', steps, t0, rows.length);
186
499
  // не switch: check-docs принимает case-метки этого файла за имена, которые Proxy разбирает раньше классов
187
500
  if (mode === 'paths')
@@ -199,12 +512,18 @@ async function plan(ctx, steps, mods, mode, agg) {
199
512
  * которая перестала подходить под условия шага, в ответ не попадает; подходящая, но не
200
513
  * заблокированная — acl_denied.
201
514
  */
202
- async function lockedRead(ctx, steps, mods, mode) {
515
+ async function lockedRead(ctx, steps, mods, mode, agg) {
203
516
  if (!ctx.exec.explicit)
204
517
  throw lerr('api.for_update_outside_tx');
205
- if ((mode !== 'rows' && mode !== 'ids') || mods.asOf !== undefined || mods.withDeleted || steps.some((s) => s.deepMax !== undefined || s.op)) {
206
- throw lerr('api.for_update_unsupported');
207
- }
518
+ // что именно мешает блокировке — в тексте, что допустимо — в подсказке (второй раунд 2.1, A7)
519
+ const op = steps.find((s) => s.op)?.op;
520
+ const what = op ? `${op.kind}()`
521
+ : mode !== 'rows' && mode !== 'ids' ? `${mode === 'agg' ? agg?.fn ?? 'agg' : mode === 'countPaths' ? 'count' : mode}()`
522
+ : mods.asOf !== undefined ? 'asOf()'
523
+ : steps.some((s) => s.withDeleted) ? 'withDeleted()'
524
+ : steps.some((s) => s.deepMax !== undefined) ? 'deep()' : undefined;
525
+ if (what)
526
+ throw lerr('api.for_update_unsupported', { what });
208
527
  const S = `"${ctx.schema.replace(/"/g, '""')}"`;
209
528
  const { forUpdate: _f, ...plain } = mods;
210
529
  const idsQ = buildRead(ctx.reg, ctx.schema, steps, plain, 'ids');
@@ -215,7 +534,7 @@ async function lockedRead(ctx, steps, mods, mode) {
215
534
  const ids = (await q.unsafe(idsQ.text, idsQ.params, { prepare: true })).map((r) => r.id);
216
535
  if (!ids.length)
217
536
  return [];
218
- const locked = new Set((await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for no key update`, [ids], { prepare: true })).map((r) => r.id));
537
+ const locked = await lockIds(q, S, ids);
219
538
  const want = new Set(ids);
220
539
  const fresh = (await q.unsafe(rowsQ.text, rowsQ.params, { prepare: true }))
221
540
  .filter((r) => want.has(r.row.id));
@@ -224,15 +543,37 @@ async function lockedRead(ctx, steps, mods, mode) {
224
543
  throw lerr('api.lock_denied', { ids: denied.join(', ') }, { detail: { ids: denied } });
225
544
  return fresh;
226
545
  });
227
- report(ctx, mode, steps, t0, res.length);
546
+ report(ctx, mode === 'ids' ? 'ids' : 'rows', steps, t0, res.length);
228
547
  return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
229
548
  }
549
+ /**
550
+ * Промис versions(): методы цепочки на нём (versions().rows(), versions().sort()) — ошибка letopis с подсказкой, а не
551
+ * TypeError «… is not a function» (3.0). Сам промис уже идёт: его отказ гасится, иначе он ушёл бы в unhandledRejection.
552
+ */
553
+ function versionsPromise(p) {
554
+ for (const method of CHAIN_METHODS) {
555
+ if (method === 'then')
556
+ continue;
557
+ Object.defineProperty(p, method, {
558
+ value: () => {
559
+ p.catch(() => { });
560
+ throw lerr('api.versions_terminal', { method });
561
+ },
562
+ });
563
+ }
564
+ return p;
565
+ }
230
566
  /** Причины для плана: по умолчанию у глаголов (у батча — своя) и та, что стоит в транзакции. */
231
567
  const reasons = (ctx) => ({ reason: ctx.reason ?? ctx.txReason ?? '', txReason: ctx.txReason ?? '' });
232
568
  /** Опция reason глагола (план 1.1, §2.1): проверяет precheckPlan. */
233
569
  const reasonOf = (o) => (o?.reason !== undefined ? { reason: o.reason } : {});
234
- /** Опция all у update() и delete({ confirm: true }): правка всех целей без фильтра (план 2.0, этап 4, п. 2). */
570
+ /**
571
+ * Опция all у update() и delete({ confirm: true }): правка всех целей без фильтра (план 2.0, этап 4, п. 2); у
572
+ * delete({ confirm: true }) и restore() ещё — без сверки с expect (3.0).
573
+ */
235
574
  const allOf = (o) => (o?.all === true ? { all: true } : {});
575
+ /** Опция expect у restore() и delete({ confirm: true }) (план 2.1, A1, A2): форму проверяет precheckPlan. */
576
+ const expectOf = (o) => (o?.expect !== undefined ? { expect: o.expect } : {});
236
577
  /** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
237
578
  const rowOf = (r) => (r.done ? r.row : toRow(r.row));
238
579
  /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
@@ -253,7 +594,15 @@ function slotValue(v) {
253
594
  }
254
595
  function classProp(ctx, name, found, ownerSteps, mods, nodes) {
255
596
  const { cls, role } = found;
256
- const step = (filter) => {
597
+ const step = (...args) => {
598
+ const filter = args[0];
599
+ // A13: X(undefined) — фильтра нет, шаг найдёт все строки (как X()); чаще всего это пустая переменная с id
600
+ if (args.length && filter === undefined)
601
+ lwarn('api.step_undefined_filter', { name }, { once: `undefined-filter|${name}`, log: ctx.log });
602
+ // A13: sort() относится к последнему шагу цепочки — шаг после него забирает сортировку себе
603
+ if (ownerSteps && mods.order !== undefined) {
604
+ lwarn('api.sort_not_last', { field: mods.order, step: name }, { once: `sort-not-last|${mods.order}|${name}`, log: ctx.log });
605
+ }
257
606
  const prev = ownerSteps?.[ownerSteps.length - 1];
258
607
  // повтор связи в пути — возврат к её узлу
259
608
  const node = prev && cls.kind === 'link' && !role ? pivotNodeOf(ownerSteps, cls.name) : undefined;
@@ -288,10 +637,16 @@ function classProp(ctx, name, found, ownerSteps, mods, nodes) {
288
637
  ctx.batch.entry.steps = next;
289
638
  return makeChain(ctx, next, mods, nodes);
290
639
  };
291
- return new Proxy(step, {
640
+ // ошибка шага или слота (переход, слот без глагола, …) — событие error с цепочкой до него
641
+ const log = ctx.log;
642
+ const g = (op, fn) => (log?.enabled
643
+ ? (...a) => guarded(log, () => ({ op, chain: chainText(log, ownerSteps ?? [], mods) }), () => fn(...a))
644
+ : fn);
645
+ const stepFn = g(name, step);
646
+ return new Proxy(stepFn, {
292
647
  get(t, p, r) {
293
648
  if (p === 'set' || p === 'unset' || p === 'add' || p === 'remove')
294
- return slot(p);
649
+ return g(`${name}.${p}`, slot(p));
295
650
  return Reflect.get(t, p, r);
296
651
  },
297
652
  });
@@ -368,9 +723,11 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
368
723
  }
369
724
  throw lerr('api.entity_arg');
370
725
  };
371
- const agg = (fn) => async (field) => {
726
+ /** Терминал: время, событие (read, write, delete, restore) и error — traceTerm. */
727
+ const term = (name, fn) => (...a) => traceTerm(ctx, steps, mods, name, a, (st) => fn(st, ...a));
728
+ const agg = (fn) => term(fn, async (st, field) => {
372
729
  afterNotPaged(mods, fn);
373
- const { q, res } = await read(ctx, steps, mods, 'agg', { fn, field });
730
+ const { q, res } = await read(ctx, steps, mods, 'agg', { fn, field }, st);
374
731
  if (fn === 'countBy')
375
732
  return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
376
733
  const v = res[0]?.v;
@@ -381,7 +738,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
381
738
  if (fn === 'sum' || fn === 'avg' || q?.aggLeaf === 'numeric')
382
739
  return Number(v);
383
740
  return v;
384
- };
741
+ });
385
742
  /** Свойство цепочки: метод или модификатор — функция, иначе шаг класса, алиаса или роли. */
386
743
  const member = (prop) => {
387
744
  switch (prop) {
@@ -390,42 +747,42 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
390
747
  case 'entity':
391
748
  return entityStep;
392
749
  case 'paths':
393
- return async () => {
750
+ return term('paths', async (st) => {
394
751
  checkAfterVerb(steps, mods);
395
752
  // q === null — пути из результата глагола: строки уже готовы. Ключи — в порядке шагов (jsonb хранит их
396
753
  // по длине), узел последнего шага — последним: по нему cursorOf(путь, поле) берёт курсор (план 2.0, 7.1 №3)
397
- const { q, res } = await read(ctx, steps, mods, 'paths');
754
+ const { q, res } = await read(ctx, steps, mods, 'paths', undefined, st);
398
755
  if (q === null)
399
756
  return res.map((r) => r.path);
400
757
  return res.map((r) => Object.fromEntries(q.keys.map((k) => [k, toRow(r.path[k])])));
401
- };
758
+ });
402
759
  case 'rows':
403
- return async () => {
760
+ return term('rows', async (st) => {
404
761
  checkAfterVerb(steps, mods);
405
- return (await read(ctx, steps, mods, 'rows')).res.map(rowOf);
406
- };
762
+ return (await read(ctx, steps, mods, 'rows', undefined, st)).res.map(rowOf);
763
+ });
407
764
  case 'first':
408
- return async () => {
765
+ return term('first', async (st) => {
409
766
  checkAfterVerb(steps, mods);
410
- const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows');
767
+ const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows', undefined, st);
411
768
  return res[0] ? rowOf(res[0]) : null;
412
- };
769
+ });
413
770
  case 'ids':
414
- return async () => {
771
+ return term('ids', async (st) => {
415
772
  checkAfterVerb(steps, mods);
416
- return (await read(ctx, steps, mods, 'ids')).res.map((r) => r.id);
417
- };
773
+ return (await read(ctx, steps, mods, 'ids', undefined, st)).res.map((r) => r.id);
774
+ });
418
775
  case 'count':
419
- return async (opts) => {
776
+ return term('count', async (st, opts) => {
420
777
  checkAfterVerb(steps, mods);
421
778
  afterNotPaged(mods, 'count');
422
- return (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count')).res[0].n;
423
- };
779
+ return (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count', undefined, st)).res[0].n;
780
+ });
424
781
  case 'versions':
425
- return async () => {
426
- warnNoHistory(steps, 'versions');
427
- return (await read(ctx, steps, mods, 'versions')).res.map((r) => toRow(r.row));
428
- };
782
+ return (...a) => versionsPromise(term('versions', async (st) => {
783
+ warnNoHistory(steps, 'versions', ctx.log);
784
+ return (await read(ctx, steps, mods, 'versions', undefined, st)).res.map((r) => toRow(r.row));
785
+ })(...a));
429
786
  case 'sum':
430
787
  case 'avg':
431
788
  case 'min':
@@ -440,13 +797,14 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
440
797
  return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
441
798
  case 'asOf':
442
799
  return (t) => {
443
- warnNoHistory(steps, 'asOf');
800
+ warnNoHistory(steps, 'asOf', ctx.log);
444
801
  return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
445
802
  };
446
803
  case 'after':
447
804
  return (cursor) => withMods({ after: cursor });
448
805
  case 'withDeleted':
449
- return () => withMods({ withDeleted: true });
806
+ // модификатор своего шага (план 2.1, A9): X(id).withDeleted().Y().withDeleted() — удалённые Y удалённого X
807
+ return () => withLast({ withDeleted: true });
450
808
  case 'deep':
451
809
  return (max = 32) => withLast({ deepMax: max });
452
810
  case 'exact':
@@ -459,13 +817,13 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
459
817
  return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
460
818
  // глаголы записи — звенья плана (этап 4)
461
819
  case 'create':
462
- return (data = {}, opts) => withOp({ kind: 'create', data, ...reasonOf(opts) });
820
+ return (data = {}, opts) => withOp({ kind: 'create', data: dropUndefined(data), ...reasonOf(opts) });
463
821
  case 'update':
464
822
  return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...allOf(opts), ...reasonOf(opts) });
465
823
  case 'upsert':
466
- return (data = {}, opts) => withOp({ kind: 'upsert', data, ...reasonOf(opts) });
824
+ return (data = {}, opts) => withOp({ kind: 'upsert', data: dropUndefined(data), ...reasonOf(opts) });
467
825
  case 'delete':
468
- return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...allOf(opts), ...reasonOf(opts) });
826
+ return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...allOf(opts), ...reasonOf(opts), ...expectOf(opts) });
469
827
  case 'set':
470
828
  case 'unset':
471
829
  case 'add':
@@ -474,7 +832,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
474
832
  case 'forUpdate':
475
833
  return () => withMods({ forUpdate: true });
476
834
  case 'restore':
477
- return (opts) => withOp({ kind: 'restore', ...reasonOf(opts) });
835
+ return (opts) => withOp({ kind: 'restore', ...allOf(opts), ...reasonOf(opts), ...expectOf(opts), ...(opts?.partial === true ? { partial: true } : {}) });
478
836
  case 'cache':
479
837
  return (opts) => {
480
838
  if (opts?.ttl !== undefined && !(opts.ttl > 0))
@@ -482,16 +840,29 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
482
840
  return withMods({ cache: opts ?? {} });
483
841
  };
484
842
  }
485
- const found = lookupStep(ctx.reg, prop) ?? unknownName(ctx.reg, prop);
486
- return classProp(ctx, prop, found, steps, mods, nodes);
843
+ const found = lookupStep(ctx.reg, prop);
844
+ if (!found && noSession(ctx, prop))
845
+ return sessionless();
846
+ return classProp(ctx, prop, found ?? unknownName(ctx, prop), steps, mods, nodes);
487
847
  };
848
+ const log = ctx.log;
488
849
  const handler = {
489
850
  get(_t, prop) {
490
851
  if (prop === PLAN)
491
852
  return { steps, mods };
853
+ if (prop === Symbol.iterator)
854
+ return notIterable;
492
855
  if (typeof prop === 'symbol')
493
856
  return undefined;
494
- return member(prop);
857
+ if (!log?.enabled)
858
+ return member(prop);
859
+ // ошибка имени (неизвестный шаг, слот как метод) и модификатора или глагола (после глагола, неверный аргумент) —
860
+ // событие error с цепочкой до него; терминалы пишут свои события сами (traceTerm)
861
+ const where = () => ({ op: prop, chain: chainText(log, steps, mods) });
862
+ const got = guarded(log, where, () => member(prop));
863
+ return typeof got === 'function' && GUARDED.has(prop)
864
+ ? (...a) => guarded(log, where, () => got(...a))
865
+ : got;
495
866
  },
496
867
  };
497
868
  return new Proxy({}, handler);
@@ -505,18 +876,26 @@ export function makeBatch(ctx, queue) {
505
876
  const member = (prop) => {
506
877
  switch (prop) {
507
878
  case 'run':
508
- return async () => {
879
+ return () => traced(ctx.log ?? NOLOG, 'write', { op: 'batch' }, async () => {
509
880
  const entries = queue.splice(0, queue.length);
510
881
  const loose = entries.find((x) => x.loose)?.loose;
511
882
  if (loose)
512
883
  throw afterVerbError(loose.name, loose.verb);
513
884
  const plans = entries.map((x) => x.steps);
514
- for (const p of plans)
515
- precheckPlan(ctx.reg, p);
885
+ try {
886
+ for (const p of plans)
887
+ precheckPlan(ctx.reg, p);
888
+ }
889
+ catch (e) {
890
+ throw await completeIssues(ctx, e);
891
+ }
516
892
  if (!plans.length)
517
893
  return [];
518
894
  return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
519
- };
895
+ }, (out) => {
896
+ const rows = out.flat();
897
+ return { rows: rows.length, detail: { plans: out.length, classes: byClass(rows), ids: idsOf(rows) } };
898
+ });
520
899
  case 'discard':
521
900
  return () => void queue.splice(0, queue.length);
522
901
  case 'size':
@@ -534,8 +913,10 @@ export function makeBatch(ctx, queue) {
534
913
  }
535
914
  /** Старт пути от корня db: класс, алиас или роль. */
536
915
  export function startStep(ctx, name) {
537
- const found = lookupStep(ctx.reg, name) ?? unknownName(ctx.reg, name);
538
- return classProp(ctx, name, found, undefined, {}, NO_NODES);
916
+ const found = lookupStep(ctx.reg, name);
917
+ if (!found && noSession(ctx, name))
918
+ return sessionless();
919
+ return classProp(ctx, name, found ?? unknownName(ctx, name), undefined, {}, NO_NODES);
539
920
  }
540
921
  /** Старт пути с готового узла: db.entity(row | цепочка). */
541
922
  export function startEntity(ctx, x) {