letopis 1.4.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/AGENT-CHEATSHEET.md +516 -398
  2. package/CHANGELOG.md +739 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +1312 -1190
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +81 -1
  8. package/dist/acl.js +281 -13
  9. package/dist/admin.d.ts +11 -141
  10. package/dist/admin.js +28 -186
  11. package/dist/auth.d.ts +20 -5
  12. package/dist/auth.js +80 -21
  13. package/dist/cache.d.ts +8 -1
  14. package/dist/cache.js +30 -8
  15. package/dist/chain.d.ts +46 -48
  16. package/dist/chain.js +556 -223
  17. package/dist/cli.js +73 -96
  18. package/dist/demo/booking.d.ts +352 -36
  19. package/dist/describe.d.ts +47 -0
  20. package/dist/describe.js +195 -0
  21. package/dist/errors.d.ts +77 -13
  22. package/dist/errors.js +231 -30
  23. package/dist/index.d.ts +62 -74
  24. package/dist/index.js +312 -155
  25. package/dist/internal.d.ts +7 -0
  26. package/dist/jsonschema.js +18 -17
  27. package/dist/load.d.ts +29 -17
  28. package/dist/load.js +235 -190
  29. package/dist/log.d.ts +121 -0
  30. package/dist/log.js +633 -0
  31. package/dist/mcp.d.ts +5 -7
  32. package/dist/mcp.js +194 -225
  33. package/dist/messages/access.d.ts +90 -0
  34. package/dist/messages/access.js +113 -0
  35. package/dist/messages/api.d.ts +237 -0
  36. package/dist/messages/api.js +304 -0
  37. package/dist/messages/boot.d.ts +201 -0
  38. package/dist/messages/boot.js +256 -0
  39. package/dist/messages/codes.d.ts +172 -0
  40. package/dist/messages/codes.js +173 -0
  41. package/dist/messages/index.d.ts +98 -0
  42. package/dist/messages/index.js +121 -0
  43. package/dist/messages/log.d.ts +229 -0
  44. package/dist/messages/log.js +102 -0
  45. package/dist/messages/mcp.d.ts +306 -0
  46. package/dist/messages/mcp.js +387 -0
  47. package/dist/messages/model.d.ts +256 -0
  48. package/dist/messages/model.js +314 -0
  49. package/dist/messages/query.d.ts +141 -0
  50. package/dist/messages/query.js +171 -0
  51. package/dist/messages/sql-access.d.ts +70 -0
  52. package/dist/messages/sql-access.js +87 -0
  53. package/dist/messages/sql-core.d.ts +164 -0
  54. package/dist/messages/sql-core.js +198 -0
  55. package/dist/messages/sql-time.d.ts +86 -0
  56. package/dist/messages/sql-time.js +111 -0
  57. package/dist/messages/sql-write.d.ts +246 -0
  58. package/dist/messages/sql-write.js +270 -0
  59. package/dist/messages/time.d.ts +23 -0
  60. package/dist/messages/time.js +29 -0
  61. package/dist/messages/types.d.ts +27 -0
  62. package/dist/messages/types.js +5 -0
  63. package/dist/messages/write.d.ts +452 -0
  64. package/dist/messages/write.js +572 -0
  65. package/dist/model.d.ts +77 -60
  66. package/dist/model.js +43 -34
  67. package/dist/ops.d.ts +35 -0
  68. package/dist/ops.js +101 -0
  69. package/dist/pglite.d.ts +0 -9
  70. package/dist/pglite.js +3 -1
  71. package/dist/registry.d.ts +3 -19
  72. package/dist/registry.js +3 -16
  73. package/dist/signals.d.ts +1 -5
  74. package/dist/signals.js +1 -5
  75. package/dist/sql.d.ts +9 -1
  76. package/dist/sql.js +175 -107
  77. package/dist/sync.d.ts +10 -4
  78. package/dist/sync.js +90 -17
  79. package/dist/tx.d.ts +21 -2
  80. package/dist/tx.js +76 -13
  81. package/dist/typed.d.ts +252 -53
  82. package/dist/types.d.ts +93 -90
  83. package/dist/types.js +17 -14
  84. package/dist/up.d.ts +56 -35
  85. package/dist/up.js +229 -83
  86. package/dist/uuid.d.ts +8 -0
  87. package/dist/uuid.js +14 -6
  88. package/dist/validate.d.ts +13 -1
  89. package/dist/validate.js +82 -33
  90. package/dist/watch.d.ts +10 -16
  91. package/dist/watch.js +36 -77
  92. package/dist/write.d.ts +87 -17
  93. package/dist/write.js +441 -206
  94. package/llms.txt +48 -31
  95. package/package.json +13 -14
  96. package/sql/10-core.sql +7 -50
  97. package/sql/15-errors.sql +64 -10
  98. package/sql/20-context.sql +18 -9
  99. package/sql/30-validate.sql +79 -30
  100. package/sql/40-class.sql +41 -115
  101. package/sql/50-acl.sql +8 -13
  102. package/sql/60-write.sql +220 -676
  103. package/sql/70-read.sql +11 -13
  104. package/sql/80-auth.sql +109 -42
  105. package/sql/90-time.sql +108 -947
  106. package/sql/95-seed.system.sql +7 -14
  107. package/sql/99-revision.sql +6 -1
  108. package/sql/README.md +32 -24
  109. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  110. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  111. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  112. package/sql/migrate/README.md +18 -0
  113. package/AGENT-CHEATSHEET.en.md +0 -488
  114. package/MIGRATION.md +0 -190
  115. package/README.en.md +0 -2910
  116. package/dist/import.d.ts +0 -185
  117. package/dist/import.js +0 -820
  118. package/dist/project.d.ts +0 -359
  119. package/dist/project.js +0 -1258
  120. package/sql/75-history.sql +0 -436
  121. package/sql/88-derived.sql +0 -1236
  122. package/sql/migrate/0006-changed-paths.sql +0 -3
  123. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/chain.js CHANGED
@@ -7,15 +7,17 @@
7
7
  * узлу (pivot): от него ветвится следующий шаг. Модификаторы возвращают новую цепочку; терминалы
8
8
  * строят один запрос (sql.ts) и исполняют его транзакцией под сессией (tx.ts).
9
9
  *
10
- * Запись (этап 4): глаголы create, update, upsert, delete, anonymize, reclass — звенья плана; слоты
10
+ * Запись (этап 4): глаголы create, update, upsert, delete — звенья плана; слоты
11
11
  * `.Роль.set/unset/add/remove` правят концы записываемой версии. Терминал цепочки с глаголами
12
12
  * исполняет весь план одной транзакцией (write.ts): продолжение идёт от результата глагола.
13
- * Этап 6: restore, purge, rekey; versions({ follow }); кэш результатов .cache() (cache.ts).
13
+ * Этап 6: restore; versions(); кэш результатов .cache() (cache.ts).
14
14
  */
15
- import { LetopisError, removed } from './errors.js';
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,9 +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
- const unknownName = (name) => {
42
- throw new LetopisError('invalid_class', `класс или роль ${name} не найдены`);
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
+ */
58
+ const unknownName = (reg, name) => {
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]' });
43
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
+ }
44
104
  const nodeOf = (steps, s) => s.pivotKey === undefined ? s : steps.find((x) => x.nodeKey === s.pivotKey) ?? s;
45
105
  /** Узел для возврата: последний реальный шаг того же класса-связи в текущем читающем сегменте. */
46
106
  function pivotNodeOf(steps, cls) {
@@ -54,7 +114,7 @@ function pivotNodeOf(steps, cls) {
54
114
  return undefined;
55
115
  }
56
116
  const noHistoryWarned = new Set();
57
- function warnNoHistory(steps, method) {
117
+ function warnNoHistory(steps, method, log) {
58
118
  const cls = steps[steps.length - 1]?.cls;
59
119
  if (!cls || cls.history !== false)
60
120
  return;
@@ -62,7 +122,7 @@ function warnNoHistory(steps, method) {
62
122
  if (noHistoryWarned.has(key))
63
123
  return;
64
124
  noHistoryWarned.add(key);
65
- console.warn(`letopis: .${method}() у класса ${cls.name} без журнала (history: false) — истории нет`);
125
+ lwarn('api.no_history', { method, cls: cls.name }, { log });
66
126
  }
67
127
  function report(ctx, mode, steps, t0, rows) {
68
128
  if (!ctx.onQuery && ctx.slowMs === undefined)
@@ -73,7 +133,233 @@ function report(ctx, mode, steps, t0, rows) {
73
133
  if (ctx.onQuery)
74
134
  ctx.onQuery({ mode, classes, ms, rows, slow });
75
135
  else if (slow)
76
- console.warn(`letopis: медленный запрос ${ms.toFixed(0)} мс — ${mode} ${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;
77
363
  }
78
364
  /** Классы, которые читает цепочка (семейства шагов): по ним сбрасывается её ответ в кэше. */
79
365
  function readClasses(reg, steps) {
@@ -91,7 +377,7 @@ function cacheTtl(ctx, steps, mods, classes) {
91
377
  return null;
92
378
  return Math.min(...marks.map((m) => (m.ttl !== undefined ? m.ttl * 1000 : c.store.ttlMs)));
93
379
  }
94
- async function exec(ctx, q, mode, steps, mods = {}) {
380
+ async function exec(ctx, q, mode, steps, mods = {}, st = {}) {
95
381
  const classes = ctx.cache ? readClasses(ctx.reg, steps) : [];
96
382
  const ttl = ctx.cache ? cacheTtl(ctx, steps, mods, classes) : null;
97
383
  if (ttl !== null) {
@@ -99,8 +385,16 @@ async function exec(ctx, q, mode, steps, mods = {}) {
99
385
  const who = ctx.cache.who();
100
386
  const key = store.key(q.text, q.params, who);
101
387
  const hit = store.get(key);
102
- 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) });
103
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 } });
104
398
  const tag = sessionTag(who.token);
105
399
  const tok = store.token(classes, tag, who.tenant);
106
400
  const t0 = performance.now();
@@ -119,18 +413,24 @@ async function exec(ctx, q, mode, steps, mods = {}) {
119
413
  report(ctx, mode, steps, t0, res.length);
120
414
  return res;
121
415
  }
122
- async function read(ctx, steps, mods, mode, agg) {
416
+ async function read(ctx, steps, mods, mode, agg, st = {}) {
123
417
  const hasOps = steps.some((s) => s.op);
124
418
  if (ctx.batch && hasOps)
125
- throw new LetopisError('invalid_query', 'план стоит в очереди батча — его исполнит batch.run()');
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
+ };
126
426
  if (mods.forUpdate)
127
- return lockedRead(ctx, steps, mods, mode);
427
+ return lockedRead(ctx, steps, mods, mode, agg).then(counted);
128
428
  if (hasOps)
129
- return plan(ctx, steps, mods, mode, agg);
429
+ return plan(ctx, steps, mods, mode, agg, st).then(counted);
130
430
  const { cache: _c, ...plain } = mods;
131
431
  const q = buildRead(ctx.reg, ctx.schema, steps, plain, mode, agg);
132
432
  const ev = mode === 'countPaths' ? 'count' : mode;
133
- 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 }));
134
434
  }
135
435
  /** Модификатор выборки, заданный в mods, — именем метода цепочки; forUpdate() отказывает сам (lock_unsupported). */
136
436
  function modName(mods) {
@@ -138,14 +438,21 @@ function modName(mods) {
138
438
  return key === 'order' || key === 'desc' ? 'sort' : key;
139
439
  }
140
440
  function afterVerbError(name, verb) {
141
- return new LetopisError('invalid_query', `${name}() после ${verb}() ни на что не действует: глагол проходит по всем целям шага, а ответ — его строки. ` +
142
- `Цели сужают limit(), offset() и sort() до глагола: db.Класс(…).limit(n).${verb}(…)`);
441
+ return lerr('api.mod_after_verb', { name, verb });
442
+ }
443
+ /**
444
+ * Курсор after() листает строки, а count() и агрегаты чтения считают по всем объектам шага: раньше курсор там молча не
445
+ * учитывался, с 2.0 — invalid_query до запроса (план 2.0, этап 4, п. 2; 7.10 №7).
446
+ */
447
+ function afterNotPaged(mods, fn) {
448
+ if (mods.after !== undefined)
449
+ throw lerr('api.after_not_paged', { fn });
143
450
  }
144
451
  /**
145
452
  * Терминалы rows, first, ids, count и paths цепочки, которая кончается глаголом, отдают строки самой правки.
146
453
  * Модификатор выборки между глаголом и терминалом ни на что не подействовал бы: limit резал бы только ответ, хотя
147
454
  * правка прошла по всем целям, остальные молча пропускались. Поэтому — отказ до записи. После глагола модификатор
148
- * относится к чтению за ним (шаг класса, versions(), diff(), blame(), агрегаты) или сужает цели следующего глагола.
455
+ * относится к чтению за ним (шаг класса, versions(), агрегаты) или сужает цели следующего глагола.
149
456
  */
150
457
  function checkAfterVerb(steps, mods) {
151
458
  const op = steps[steps.length - 1]?.op;
@@ -157,8 +464,13 @@ function checkAfterVerb(steps, mods) {
157
464
  * Цепочка с глаголами: весь план одной транзакцией (§2.5). Результат приводится к виду ответа
158
465
  * чтения (строки rows/paths, id, n, агрегаты), чтобы терминалы разбирали его одинаково.
159
466
  */
160
- async function plan(ctx, steps, mods, mode, agg) {
161
- precheckPlan(ctx.reg, steps);
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
+ }
162
474
  const t0 = performance.now();
163
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));
164
476
  if (r.kind === 'read') {
@@ -172,6 +484,7 @@ async function plan(ctx, steps, mods, mode, agg) {
172
484
  return r;
173
485
  }
174
486
  const rows = r.rows;
487
+ st.rows = rows;
175
488
  report(ctx, mode === 'countPaths' ? 'count' : mode === 'paths' ? 'paths' : mode === 'ids' ? 'ids' : 'rows', steps, t0, rows.length);
176
489
  // не switch: check-docs принимает case-метки этого файла за имена, которые Proxy разбирает раньше классов
177
490
  if (mode === 'paths')
@@ -189,12 +502,18 @@ async function plan(ctx, steps, mods, mode, agg) {
189
502
  * которая перестала подходить под условия шага, в ответ не попадает; подходящая, но не
190
503
  * заблокированная — acl_denied.
191
504
  */
192
- async function lockedRead(ctx, steps, mods, mode) {
505
+ async function lockedRead(ctx, steps, mods, mode, agg) {
193
506
  if (!ctx.exec.explicit)
194
- throw new LetopisError('tx_required', 'forUpdate() — только внутри db.begin(): вне явной транзакции блокировка снялась бы сразу');
195
- if ((mode !== 'rows' && mode !== 'ids') || mods.asOf !== undefined || mods.withDeleted || steps.some((s) => s.deepMax !== undefined || s.op)) {
196
- throw new LetopisError('lock_unsupported', 'forUpdate() — с терминалами rows, first, ids, без агрегатов, asOf, versions, withDeleted, deep и глаголов');
197
- }
507
+ throw lerr('api.for_update_outside_tx');
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 });
198
517
  const S = `"${ctx.schema.replace(/"/g, '""')}"`;
199
518
  const { forUpdate: _f, ...plain } = mods;
200
519
  const idsQ = buildRead(ctx.reg, ctx.schema, steps, plain, 'ids');
@@ -205,40 +524,46 @@ async function lockedRead(ctx, steps, mods, mode) {
205
524
  const ids = (await q.unsafe(idsQ.text, idsQ.params, { prepare: true })).map((r) => r.id);
206
525
  if (!ids.length)
207
526
  return [];
208
- 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);
209
528
  const want = new Set(ids);
210
529
  const fresh = (await q.unsafe(rowsQ.text, rowsQ.params, { prepare: true }))
211
530
  .filter((r) => want.has(r.row.id));
212
531
  const denied = fresh.filter((r) => !locked.has(r.row.id)).map((r) => r.row.id);
213
532
  if (denied.length)
214
- throw new LetopisError('acl_denied', `строки видны, но заблокировать их нельзя: ${denied.join(', ')}`, { ids: denied });
533
+ throw lerr('api.lock_denied', { ids: denied.join(', ') }, { detail: { ids: denied } });
215
534
  return fresh;
216
535
  });
217
- report(ctx, mode, steps, t0, res.length);
536
+ report(ctx, mode === 'ids' ? 'ids' : 'rows', steps, t0, res.length);
218
537
  return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
219
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
+ }
220
556
  /** Причины для плана: по умолчанию у глаголов (у батча — своя) и та, что стоит в транзакции. */
221
557
  const reasons = (ctx) => ({ reason: ctx.reason ?? ctx.txReason ?? '', txReason: ctx.txReason ?? '' });
222
558
  /** Опция reason глагола (план 1.1, §2.1): проверяет precheckPlan. */
223
559
  const reasonOf = (o) => (o?.reason !== undefined ? { reason: o.reason } : {});
224
- /** diff({ from, to }): номер версии (целое ≥ 1) или момент (ISO-строка, Date). */
225
- function versionRef(v, what, moments = true) {
226
- if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 2147483647)
227
- return v;
228
- if (moments && v instanceof Date && !Number.isNaN(+v))
229
- return v.toISOString();
230
- if (moments && typeof v === 'string' && !Number.isNaN(Date.parse(v)))
231
- return v;
232
- throw new LetopisError('invalid_query', `${what}: номер версии (целое ≥ 1)${moments ? ' или момент (ISO-строка, Date)' : ''}`);
233
- }
234
- /** blame({ at }): момент (ISO-строка, Date). */
235
- function momentOf(v, what) {
236
- if (v instanceof Date && !Number.isNaN(+v))
237
- return v.toISOString();
238
- if (typeof v === 'string' && !Number.isNaN(Date.parse(v)))
239
- return v;
240
- throw new LetopisError('invalid_query', `${what}: момент — ISO-строка или Date`);
241
- }
560
+ /**
561
+ * Опция all у update() и delete({ confirm: true }): правка всех целей без фильтра (план 2.0, этап 4, п. 2); у
562
+ * delete({ confirm: true }) и restore() ещё — без сверки с expect (3.0).
563
+ */
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 } : {});
242
567
  /** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
243
568
  const rowOf = (r) => (r.done ? r.row : toRow(r.row));
244
569
  /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
@@ -255,11 +580,19 @@ function slotValue(v) {
255
580
  return v.map((x) => (typeof x === 'string' ? x : x.id));
256
581
  if (typeof v === 'object' && typeof v.id === 'string')
257
582
  return { id: v.id };
258
- throw new LetopisError('invalid_query', 'значение слота — id, список id, строка ответа или цепочка');
583
+ throw lerr('api.slot_value');
259
584
  }
260
585
  function classProp(ctx, name, found, ownerSteps, mods, nodes) {
261
586
  const { cls, role } = found;
262
- 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
+ }
263
596
  const prev = ownerSteps?.[ownerSteps.length - 1];
264
597
  // повтор связи в пути — возврат к её узлу
265
598
  const node = prev && cls.kind === 'link' && !role ? pivotNodeOf(ownerSteps, cls.name) : undefined;
@@ -276,16 +609,16 @@ function classProp(ctx, name, found, ownerSteps, mods, nodes) {
276
609
  const last = ownerSteps?.[ownerSteps.length - 1];
277
610
  const kind = last?.op?.kind;
278
611
  if (!last || (kind !== 'create' && kind !== 'update' && kind !== 'upsert')) {
279
- throw new LetopisError('invalid_query', `слот .${name}.${op}() — после create(), update() или upsert(): db.Класс(…).update(…).${name}.${op}(…)`);
612
+ throw lerr('api.slot_without_write', { name, op });
280
613
  }
281
614
  const role = slotRole(ctx.reg, last.cls, name);
282
615
  const end = last.cls.ends[role];
283
616
  if ((op === 'add' || op === 'remove') && !end.many)
284
- throw new LetopisError('invalid_query', `слот .${name}.${op}(): конец ${role} одиночный — set() или unset()`);
617
+ throw lerr('api.slot_single', { name, op, role });
285
618
  if (op === 'unset' && (end.required || (end.min ?? 0) > 0))
286
- throw new LetopisError('invalid_query', `слот .${name}.unset(): конец ${role} обязателен`);
619
+ throw lerr('api.slot_required', { name, role });
287
620
  if ((op === 'set' || op === 'unset') && last.slots?.some((x) => x.role === role && (x.op === 'set' || x.op === 'unset'))) {
288
- throw new LetopisError('invalid_query', `слот ${role} задан дважды в одной записи`);
621
+ throw lerr('api.slot_twice', { role });
289
622
  }
290
623
  const value = op === 'unset' ? null : slotValue(v);
291
624
  const next = ownerSteps.slice();
@@ -294,10 +627,16 @@ function classProp(ctx, name, found, ownerSteps, mods, nodes) {
294
627
  ctx.batch.entry.steps = next;
295
628
  return makeChain(ctx, next, mods, nodes);
296
629
  };
297
- 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, {
298
637
  get(t, p, r) {
299
638
  if (p === 'set' || p === 'unset' || p === 'add' || p === 'remove')
300
- return slot(p);
639
+ return g(`${name}.${p}`, slot(p));
301
640
  return Reflect.get(t, p, r);
302
641
  },
303
642
  });
@@ -313,7 +652,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
313
652
  const withLast = (patch) => {
314
653
  const lastOp = steps[steps.length - 1]?.op;
315
654
  if (lastOp)
316
- throw new LetopisError('invalid_query', `модификатор шага после ${lastOp.kind}() — сначала шаг класса`);
655
+ throw lerr('api.step_mod_after_verb', { verb: lastOp.kind });
317
656
  const next = steps.slice();
318
657
  next[next.length - 1] = { ...steps[steps.length - 1], ...patch };
319
658
  return makeChain(ctx, next, mods, nodes);
@@ -335,7 +674,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
335
674
  const withOp = (op) => {
336
675
  const last = steps[steps.length - 1];
337
676
  if (!last)
338
- throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
677
+ throw lerr('api.verb_without_step', { verb: op.kind });
339
678
  // батч: причина db.batch(имя, { reason }) — при постановке в очередь, а не при run(): очередь общая по имени, и
340
679
  // run() может позвать фасад без причины или с другой (ревью №1 точки А плана 1.1)
341
680
  if (ctx.batch && op.reason === undefined && ctx.reason !== undefined)
@@ -350,7 +689,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
350
689
  const plan = peekPlan(x);
351
690
  const prev = steps.length ? nodeOf(steps, steps[steps.length - 1]) : undefined;
352
691
  if (plan?.steps.some((s) => s.op))
353
- throw new LetopisError('invalid_query', 'entity() принимает читающий паттерн — цепочку с глаголами вклеить нельзя');
692
+ throw lerr('api.entity_with_verbs');
354
693
  if (plan) {
355
694
  const known = nodes.get(x);
356
695
  if (known !== undefined) {
@@ -372,10 +711,13 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
372
711
  resolveHop(ctx.reg, prev, next);
373
712
  return makeChain(ctx, [...steps, next], mods, nodes);
374
713
  }
375
- throw new LetopisError('invalid_query', 'entity() принимает ленивую цепочку или строку ответа');
714
+ throw lerr('api.entity_arg');
376
715
  };
377
- const agg = (fn) => async (field) => {
378
- const { q, res } = await read(ctx, steps, mods, 'agg', { fn, 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) => {
719
+ afterNotPaged(mods, fn);
720
+ const { q, res } = await read(ctx, steps, mods, 'agg', { fn, field }, st);
379
721
  if (fn === 'countBy')
380
722
  return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
381
723
  const v = res[0]?.v;
@@ -386,151 +728,131 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
386
728
  if (fn === 'sum' || fn === 'avg' || q?.aggLeaf === 'numeric')
387
729
  return Number(v);
388
730
  return v;
731
+ });
732
+ /** Свойство цепочки: метод или модификатор — функция, иначе шаг класса, алиаса или роли. */
733
+ const member = (prop) => {
734
+ switch (prop) {
735
+ case 'then':
736
+ return undefined;
737
+ case 'entity':
738
+ return entityStep;
739
+ case 'paths':
740
+ return term('paths', async (st) => {
741
+ checkAfterVerb(steps, mods);
742
+ // q === null — пути из результата глагола: строки уже готовы. Ключи — в порядке шагов (jsonb хранит их
743
+ // по длине), узел последнего шага — последним: по нему cursorOf(путь, поле) берёт курсор (план 2.0, 7.1 №3)
744
+ const { q, res } = await read(ctx, steps, mods, 'paths', undefined, st);
745
+ if (q === null)
746
+ return res.map((r) => r.path);
747
+ return res.map((r) => Object.fromEntries(q.keys.map((k) => [k, toRow(r.path[k])])));
748
+ });
749
+ case 'rows':
750
+ return term('rows', async (st) => {
751
+ checkAfterVerb(steps, mods);
752
+ return (await read(ctx, steps, mods, 'rows', undefined, st)).res.map(rowOf);
753
+ });
754
+ case 'first':
755
+ return term('first', async (st) => {
756
+ checkAfterVerb(steps, mods);
757
+ const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows', undefined, st);
758
+ return res[0] ? rowOf(res[0]) : null;
759
+ });
760
+ case 'ids':
761
+ return term('ids', async (st) => {
762
+ checkAfterVerb(steps, mods);
763
+ return (await read(ctx, steps, mods, 'ids', undefined, st)).res.map((r) => r.id);
764
+ });
765
+ case 'count':
766
+ return term('count', async (st, opts) => {
767
+ checkAfterVerb(steps, mods);
768
+ afterNotPaged(mods, 'count');
769
+ return (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count', undefined, st)).res[0].n;
770
+ });
771
+ case 'versions':
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));
776
+ case 'sum':
777
+ case 'avg':
778
+ case 'min':
779
+ case 'max':
780
+ case 'countBy':
781
+ return agg(prop);
782
+ case 'limit':
783
+ return (n) => withMods({ limit: n });
784
+ case 'offset':
785
+ return (n) => withMods({ offset: n });
786
+ case 'sort':
787
+ return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
788
+ case 'asOf':
789
+ return (t) => {
790
+ warnNoHistory(steps, 'asOf', ctx.log);
791
+ return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
792
+ };
793
+ case 'after':
794
+ return (cursor) => withMods({ after: cursor });
795
+ case 'withDeleted':
796
+ // модификатор своего шага (план 2.1, A9): X(id).withDeleted().Y().withDeleted() — удалённые Y удалённого X
797
+ return () => withLast({ withDeleted: true });
798
+ case 'deep':
799
+ return (max = 32) => withLast({ deepMax: max });
800
+ case 'exact':
801
+ return () => withLast({ exact: true });
802
+ case 'alias':
803
+ return (name) => withLast({ aliasKey: name });
804
+ case 'tags':
805
+ return (v) => withLast({ tagsFilter: v });
806
+ case 'owner':
807
+ return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
808
+ // глаголы записи — звенья плана (этап 4)
809
+ case 'create':
810
+ return (data = {}, opts) => withOp({ kind: 'create', data: dropUndefined(data), ...reasonOf(opts) });
811
+ case 'update':
812
+ return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...allOf(opts), ...reasonOf(opts) });
813
+ case 'upsert':
814
+ return (data = {}, opts) => withOp({ kind: 'upsert', data: dropUndefined(data), ...reasonOf(opts) });
815
+ case 'delete':
816
+ return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...allOf(opts), ...reasonOf(opts), ...expectOf(opts) });
817
+ case 'set':
818
+ case 'unset':
819
+ case 'add':
820
+ case 'remove':
821
+ throw lerr('api.slot_method', { method: prop });
822
+ case 'forUpdate':
823
+ return () => withMods({ forUpdate: true });
824
+ case 'restore':
825
+ return (opts) => withOp({ kind: 'restore', ...allOf(opts), ...reasonOf(opts), ...expectOf(opts), ...(opts?.partial === true ? { partial: true } : {}) });
826
+ case 'cache':
827
+ return (opts) => {
828
+ if (opts?.ttl !== undefined && !(opts.ttl > 0))
829
+ throw lerr('api.cache_ttl');
830
+ return withMods({ cache: opts ?? {} });
831
+ };
832
+ }
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);
389
837
  };
838
+ const log = ctx.log;
390
839
  const handler = {
391
840
  get(_t, prop) {
392
841
  if (prop === PLAN)
393
842
  return { steps, mods };
843
+ if (prop === Symbol.iterator)
844
+ return notIterable;
394
845
  if (typeof prop === 'symbol')
395
846
  return undefined;
396
- switch (prop) {
397
- case 'then':
398
- return undefined;
399
- case 'entity':
400
- return entityStep;
401
- case 'paths':
402
- return async () => {
403
- checkAfterVerb(steps, mods);
404
- // q === null — пути из результата глагола: строки уже готовы. Ключи — в порядке шагов (jsonb хранит их
405
- // по длине), узел последнего шага — последним: по нему cursorOf(путь, поле) берёт курсор (план 2.0, 7.1 №3)
406
- const { q, res } = await read(ctx, steps, mods, 'paths');
407
- if (q === null)
408
- return res.map((r) => r.path);
409
- return res.map((r) => Object.fromEntries(q.keys.map((k) => [k, toRow(r.path[k])])));
410
- };
411
- case 'rows':
412
- return async () => {
413
- checkAfterVerb(steps, mods);
414
- return (await read(ctx, steps, mods, 'rows')).res.map(rowOf);
415
- };
416
- case 'first':
417
- return async () => {
418
- checkAfterVerb(steps, mods);
419
- const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows');
420
- return res[0] ? rowOf(res[0]) : null;
421
- };
422
- case 'ids':
423
- return async () => {
424
- checkAfterVerb(steps, mods);
425
- return (await read(ctx, steps, mods, 'ids')).res.map((r) => r.id);
426
- };
427
- case 'count':
428
- return async (opts) => {
429
- checkAfterVerb(steps, mods);
430
- return (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count')).res[0].n;
431
- };
432
- case 'versions':
433
- return async (opts) => {
434
- warnNoHistory(steps, 'versions');
435
- const m = opts?.follow ? { ...mods, follow: true } : mods;
436
- return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
437
- };
438
- case 'diff':
439
- return async (opts = {}) => {
440
- warnNoHistory(steps, 'diff');
441
- const hist = {
442
- ...(opts.from !== undefined ? { from: versionRef(opts.from, 'diff({ from })') } : {}),
443
- ...(opts.to !== undefined ? { to: versionRef(opts.to, 'diff({ to })') } : {}),
444
- ...(opts.follow ? { follow: true } : {}),
445
- };
446
- return (await read(ctx, steps, { ...mods, hist }, 'diff')).res.map((r) => r.row);
447
- };
448
- case 'blame':
449
- return async (opts = {}) => {
450
- warnNoHistory(steps, 'blame');
451
- if (opts.limit !== undefined)
452
- versionRef(opts.limit, 'blame({ limit })', false);
453
- const hist = {
454
- ...(opts.at !== undefined ? { at: momentOf(opts.at, 'blame({ at })') } : {}),
455
- ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
456
- };
457
- // null — объекта на момент at не было (или он не виден)
458
- return (await read(ctx, steps, { ...mods, hist }, 'blame')).res
459
- .map((r) => r.row).filter((x) => x !== null);
460
- };
461
- case 'sum':
462
- case 'avg':
463
- case 'min':
464
- case 'max':
465
- case 'countBy':
466
- return agg(prop);
467
- case 'limit':
468
- return (n) => withMods({ limit: n });
469
- case 'offset':
470
- return (n) => withMods({ offset: n });
471
- case 'sort':
472
- return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
473
- case 'asOf':
474
- return (t) => {
475
- warnNoHistory(steps, 'asOf');
476
- return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
477
- };
478
- case 'after':
479
- return (cursor) => withMods({ after: cursor });
480
- case 'withDeleted':
481
- return () => withMods({ withDeleted: true });
482
- case 'deep':
483
- return (max = 32) => withLast({ deepMax: max });
484
- case 'exact':
485
- return () => withLast({ exact: true });
486
- case 'alias':
487
- return (name) => withLast({ aliasKey: name });
488
- case 'tags':
489
- return (v) => withLast({ tagsFilter: v });
490
- case 'owner':
491
- return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
492
- // охранники снесённого API (этап 3, п. 11)
493
- case 'run':
494
- throw removed('run()', 'paths()');
495
- case 'execute':
496
- throw removed('execute()', 'paths()');
497
- case 'account':
498
- throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
499
- // глаголы записи — звенья плана (этап 4)
500
- case 'create':
501
- return (data = {}, opts) => withOp({ kind: 'create', data, ...reasonOf(opts) });
502
- case 'update':
503
- return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...reasonOf(opts) });
504
- case 'upsert':
505
- return (data = {}, opts) => withOp({ kind: 'upsert', data, ...reasonOf(opts) });
506
- case 'delete':
507
- return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...reasonOf(opts) });
508
- case 'anonymize':
509
- return (fields, opts) => withOp({ kind: 'anonymize', fields, ...reasonOf(opts) });
510
- case 'reclass':
511
- return (cls, data, opts) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}), ...reasonOf(opts) });
512
- case 'set':
513
- case 'unset':
514
- case 'add':
515
- case 'remove':
516
- throw new LetopisError('invalid_query', `${prop}() — метод слота: db.Класс(…).update(…).Роль.${prop}(…)`);
517
- case 'forUpdate':
518
- return () => withMods({ forUpdate: true });
519
- case 'restore':
520
- return (opts) => withOp({ kind: 'restore', ...reasonOf(opts) });
521
- case 'purge':
522
- return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, ...reasonOf(opts) });
523
- case 'rekey':
524
- return (patch, opts) => withOp({ kind: 'rekey', data: patch ?? {}, ...reasonOf(opts) });
525
- case 'cache':
526
- return (opts) => {
527
- if (opts?.ttl !== undefined && !(opts.ttl > 0))
528
- throw new LetopisError('invalid_query', 'cache({ ttl }): срок — положительное число секунд');
529
- return withMods({ cache: opts ?? {} });
530
- };
531
- }
532
- const found = lookupStep(ctx.reg, prop) ?? unknownName(prop);
533
- return classProp(ctx, prop, found, steps, mods, nodes);
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;
534
856
  },
535
857
  };
536
858
  return new Proxy({}, handler);
@@ -541,39 +863,50 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
541
863
  * и общей с другими фасадами быть не может: иначе план исполнился бы от имени чужого актора.
542
864
  */
543
865
  export function makeBatch(ctx, queue) {
866
+ const member = (prop) => {
867
+ switch (prop) {
868
+ case 'run':
869
+ return () => traced(ctx.log ?? NOLOG, 'write', { op: 'batch' }, async () => {
870
+ const entries = queue.splice(0, queue.length);
871
+ const loose = entries.find((x) => x.loose)?.loose;
872
+ if (loose)
873
+ throw afterVerbError(loose.name, loose.verb);
874
+ const plans = entries.map((x) => x.steps);
875
+ try {
876
+ for (const p of plans)
877
+ precheckPlan(ctx.reg, p);
878
+ }
879
+ catch (e) {
880
+ throw await completeIssues(ctx, e);
881
+ }
882
+ if (!plans.length)
883
+ return [];
884
+ return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
885
+ }, (out) => {
886
+ const rows = out.flat();
887
+ return { rows: rows.length, detail: { plans: out.length, classes: byClass(rows), ids: idsOf(rows) } };
888
+ });
889
+ case 'discard':
890
+ return () => void queue.splice(0, queue.length);
891
+ case 'size':
892
+ return () => queue.length;
893
+ }
894
+ return startStep({ ...ctx, batch: { queue } }, prop);
895
+ };
544
896
  return new Proxy({}, {
545
897
  get(_t, prop) {
546
898
  if (typeof prop === 'symbol' || prop === 'then')
547
899
  return undefined;
548
- switch (prop) {
549
- case 'run':
550
- return async () => {
551
- const entries = queue.splice(0, queue.length);
552
- const loose = entries.find((x) => x.loose)?.loose;
553
- if (loose)
554
- throw afterVerbError(loose.name, loose.verb);
555
- const plans = entries.map((x) => x.steps);
556
- for (const p of plans)
557
- precheckPlan(ctx.reg, p);
558
- if (!plans.length)
559
- return [];
560
- return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
561
- };
562
- case 'execute':
563
- throw removed('batch.execute()', 'batch.run()');
564
- case 'discard':
565
- return () => void queue.splice(0, queue.length);
566
- case 'size':
567
- return () => queue.length;
568
- }
569
- return startStep({ ...ctx, batch: { queue } }, prop);
900
+ return member(prop);
570
901
  },
571
902
  });
572
903
  }
573
904
  /** Старт пути от корня db: класс, алиас или роль. */
574
905
  export function startStep(ctx, name) {
575
- const found = lookupStep(ctx.reg, name) ?? unknownName(name);
576
- 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);
577
910
  }
578
911
  /** Старт пути с готового узла: db.entity(row | цепочка). */
579
912
  export function startEntity(ctx, x) {