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