letopis 1.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/chain.js
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
* узлу (pivot): от него ветвится следующий шаг. Модификаторы возвращают новую цепочку; терминалы
|
|
8
8
|
* строят один запрос (sql.ts) и исполняют его транзакцией под сессией (tx.ts).
|
|
9
9
|
*
|
|
10
|
-
* Запись (этап 4): глаголы create, update, upsert, delete
|
|
10
|
+
* Запись (этап 4): глаголы create, update, upsert, delete — звенья плана; слоты
|
|
11
11
|
* `.Роль.set/unset/add/remove` правят концы записываемой версии. Терминал цепочки с глаголами
|
|
12
12
|
* исполняет весь план одной транзакцией (write.ts): продолжение идёт от результата глагола.
|
|
13
|
-
* Этап 6: restore
|
|
13
|
+
* Этап 6: restore; versions(); кэш результатов .cache() (cache.ts).
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
142
|
-
|
|
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(),
|
|
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
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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 = (
|
|
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
|
|
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
|
|
617
|
+
throw lerr('api.slot_single', { name, op, role });
|
|
285
618
|
if (op === 'unset' && (end.required || (end.min ?? 0) > 0))
|
|
286
|
-
throw
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
714
|
+
throw lerr('api.entity_arg');
|
|
376
715
|
};
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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)
|
|
576
|
-
|
|
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) {
|