letopis 0.20.3 → 1.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.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -250
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -351
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -600
- package/sql/seed.auth.sql +0 -73
package/dist/chain.js
CHANGED
|
@@ -1,351 +1,375 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Цепочки чтения (план, этап 3, п. 1, 6, 7, 11): `t.Класс(фильтр).Связь().Класс().rows()`.
|
|
3
|
+
*
|
|
4
|
+
* db и цепочка — Proxy над реестром классов: имя класса, его алиас или имя роли (конца) — шаг пути.
|
|
5
|
+
* Шаг-роль ведёт именно этим концом: `t.Folder(f).Parent()` — родительская папка,
|
|
6
|
+
* `t.Referal(anna).Customer()` — клиенты, которых привела anna. Повтор связи в пути — возврат к её
|
|
7
|
+
* узлу (pivot): от него ветвится следующий шаг. Модификаторы возвращают новую цепочку; терминалы
|
|
8
|
+
* строят один запрос (sql.ts) и исполняют его транзакцией под сессией (tx.ts).
|
|
9
|
+
*
|
|
10
|
+
* Запись (этап 4): глаголы create, update, upsert, delete, anonymize, reclass — звенья плана; слоты
|
|
11
|
+
* `.Роль.set/unset/add/remove` правят концы записываемой версии. Терминал цепочки с глаголами
|
|
12
|
+
* исполняет весь план одной транзакцией (write.ts): продолжение идёт от результата глагола.
|
|
13
|
+
* Этап 6: restore, purge, rekey; versions({ follow }); кэш результатов .cache() (cache.ts).
|
|
14
|
+
*/
|
|
15
|
+
import { LetopisError, removed } from './errors.js';
|
|
16
|
+
import { sessionTag } from './cache.js';
|
|
17
|
+
import { buildRead, nextNodeKey, resolveHop, roleTargets, stepClasses, toRow, } from './sql.js';
|
|
18
|
+
import { execBatch, execPlan, precheckPlan, slotRole } from './write.js';
|
|
19
|
+
/** Внутренний канал: план чужой ленивой цепочки (entity()). */
|
|
20
|
+
export const PLAN = Symbol('letopis.plan');
|
|
21
|
+
const peekPlan = (x) => x !== null && (typeof x === 'object' || typeof x === 'function') ? x[PLAN] : undefined;
|
|
22
|
+
const NO_NODES = new Map();
|
|
23
|
+
/** Строка ответа как фильтр — её id. */
|
|
15
24
|
function normalizeFilter(f) {
|
|
16
|
-
if (f !==
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
(typeof f.class === 'string' || 'categories' in f)) {
|
|
21
|
-
return f.id;
|
|
25
|
+
if (f !== null && typeof f === 'object' && !Array.isArray(f)) {
|
|
26
|
+
const o = f;
|
|
27
|
+
if (typeof o.id === 'string' && typeof o.class === 'string')
|
|
28
|
+
return o.id;
|
|
22
29
|
}
|
|
23
30
|
return f;
|
|
24
31
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// OUTPUTS: { LinkEnd | undefined - подходящий конец связи или undefined }
|
|
34
|
-
// SIDE_EFFECTS: none
|
|
35
|
-
// LINKS: M-CHAIN, V-M-CHAIN
|
|
36
|
-
// END_CONTRACT: findEnd
|
|
37
|
-
function findEnd(owner, slot) {
|
|
38
|
-
return owner.links.find((e) => e.classes.includes(slot.id) || (!owner.strictEnds && e.classes.includes('Entity') && slot.category === 'HUB'));
|
|
39
|
-
}
|
|
40
|
-
/** Значение слота: id-строка | Row | цепочка (одношаговый адрес сворачивается в id). */
|
|
41
|
-
// START_CONTRACT: slotValue
|
|
42
|
-
// PURPOSE: Приводит значение слота к SlotValue — id-строка, Row (берётся id) или ленивая цепочка (одношаговый адрес db.Класс(id) сворачивается в id, иначе вложенный план).
|
|
43
|
-
// INPUTS: { slotCls: ClassDef - класс конца связи; v: unknown - id | Row | Chain }
|
|
44
|
-
// OUTPUTS: { SlotValue - id-строка либо { plan, mods } вложенной цепочки }
|
|
45
|
-
// SIDE_EFFECTS: бросает при цепочке чужого класса или неподдержанном значении
|
|
46
|
-
// LINKS: M-CHAIN, V-M-CHAIN
|
|
47
|
-
// END_CONTRACT: slotValue
|
|
48
|
-
function slotValue(slotCls, v) {
|
|
49
|
-
if (typeof v === 'string')
|
|
50
|
-
return v;
|
|
51
|
-
const plan = peekPlan(v);
|
|
52
|
-
if (plan) {
|
|
53
|
-
const [head] = plan.steps;
|
|
54
|
-
if (plan.steps.length === 1 && !head.op && typeof head.filter === 'string') {
|
|
55
|
-
if (head.cls.id !== slotCls.id) {
|
|
56
|
-
throw new Error(`letopis: slot "${slotCls.id}" got a chain of class "${head.cls.id}"`);
|
|
57
|
-
}
|
|
58
|
-
return head.filter; // просто адрес db.Класс(id) — без вложенного исполнения
|
|
59
|
-
}
|
|
60
|
-
return { plan: plan.steps, mods: plan.mods };
|
|
61
|
-
}
|
|
62
|
-
if (v !== null && typeof v === 'object' && typeof v.id === 'string') {
|
|
63
|
-
return v.id; // Row / Account
|
|
64
|
-
}
|
|
65
|
-
throw new Error(`letopis: slot "${slotCls.id}" accepts an id, a Row or a chain`);
|
|
32
|
+
/** Класс, алиас или роль → класс шага (для роли — первый класс её целей). */
|
|
33
|
+
export function lookupStep(reg, name) {
|
|
34
|
+
const cls = reg.find(name);
|
|
35
|
+
if (cls)
|
|
36
|
+
return { cls };
|
|
37
|
+
const targets = roleTargets(reg, name);
|
|
38
|
+
const t = targets.map((n) => reg.find(n)).find((c) => !!c);
|
|
39
|
+
return t ? { cls: t, role: name } : undefined;
|
|
66
40
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// SIDE_EFFECTS: none
|
|
73
|
-
// LINKS: M-CHAIN, V-M-CHAIN
|
|
74
|
-
// END_CONTRACT: pivotNodeOf
|
|
41
|
+
const unknownName = (name) => {
|
|
42
|
+
throw new LetopisError('invalid_class', `класс или роль ${name} не найдены`);
|
|
43
|
+
};
|
|
44
|
+
const nodeOf = (steps, s) => s.pivotKey === undefined ? s : steps.find((x) => x.nodeKey === s.pivotKey) ?? s;
|
|
45
|
+
/** Узел для возврата: последний реальный шаг того же класса-связи в текущем читающем сегменте. */
|
|
75
46
|
function pivotNodeOf(steps, cls) {
|
|
76
47
|
for (let i = steps.length - 1; i >= 0; i--) {
|
|
77
48
|
const s = steps[i];
|
|
78
49
|
if (s.op)
|
|
79
|
-
return undefined; //
|
|
80
|
-
if (s.pivotKey === undefined && s.cls.
|
|
50
|
+
return undefined; // глагол закрывает паттерн — узлы до него недоступны
|
|
51
|
+
if (s.pivotKey === undefined && !s.role && s.cls.name === cls)
|
|
81
52
|
return s;
|
|
82
53
|
}
|
|
83
54
|
return undefined;
|
|
84
55
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const res = await runQuery(ctx, q.text, q.params, 'paths', clsOf(steps));
|
|
96
|
-
return res.map((r) => {
|
|
97
|
-
const out = {};
|
|
98
|
-
for (const [k, v] of Object.entries(r.path))
|
|
99
|
-
out[k] = toRow(v);
|
|
100
|
-
return out;
|
|
101
|
-
});
|
|
102
|
-
// END_BLOCK_RUN_PATHS
|
|
56
|
+
const noHistoryWarned = new Set();
|
|
57
|
+
function warnNoHistory(steps, method) {
|
|
58
|
+
const cls = steps[steps.length - 1]?.cls;
|
|
59
|
+
if (!cls || cls.history !== false)
|
|
60
|
+
return;
|
|
61
|
+
const key = `${cls.name}|${method}`;
|
|
62
|
+
if (noHistoryWarned.has(key))
|
|
63
|
+
return;
|
|
64
|
+
noHistoryWarned.add(key);
|
|
65
|
+
console.warn(`letopis: .${method}() у класса ${cls.name} без журнала (history: false) — истории нет`);
|
|
103
66
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
67
|
+
function report(ctx, mode, steps, t0, rows) {
|
|
68
|
+
if (!ctx.onQuery && ctx.slowMs === undefined)
|
|
69
|
+
return;
|
|
70
|
+
const ms = performance.now() - t0;
|
|
71
|
+
const slow = ctx.slowMs !== undefined && ms > ctx.slowMs;
|
|
72
|
+
const classes = steps.map((s) => s.cls.name);
|
|
73
|
+
if (ctx.onQuery)
|
|
74
|
+
ctx.onQuery({ mode, classes, ms, rows, slow });
|
|
75
|
+
else if (slow)
|
|
76
|
+
console.warn(`letopis: медленный запрос ${ms.toFixed(0)} мс — ${mode} ${classes.join('→')}`);
|
|
77
|
+
}
|
|
78
|
+
/** Классы, которые читает цепочка (семейства шагов): по ним сбрасывается её ответ в кэше. */
|
|
79
|
+
function readClasses(reg, steps) {
|
|
80
|
+
return [...new Set(steps.flatMap((s) => stepClasses(reg, s)))];
|
|
81
|
+
}
|
|
82
|
+
/** Срок ответа в кэше, мс: .cache({ ttl }) или пометка cache у всех классов чтения; null — не кэшировать. */
|
|
83
|
+
function cacheTtl(ctx, steps, mods, classes) {
|
|
84
|
+
const c = ctx.cache;
|
|
85
|
+
if (!c || !c.store.enabled || ctx.exec.explicit || ctx.batch || mods.forUpdate)
|
|
86
|
+
return null;
|
|
87
|
+
if (mods.cache)
|
|
88
|
+
return mods.cache.ttl !== undefined ? mods.cache.ttl * 1000 : c.store.ttlMs;
|
|
89
|
+
const marks = classes.map((n) => ctx.reg.find(n)?.cache);
|
|
90
|
+
if (!marks.length || marks.some((m) => !m))
|
|
91
|
+
return null;
|
|
92
|
+
return Math.min(...marks.map((m) => (m.ttl !== undefined ? m.ttl * 1000 : c.store.ttlMs)));
|
|
93
|
+
}
|
|
94
|
+
async function exec(ctx, q, mode, steps, mods = {}) {
|
|
95
|
+
const classes = ctx.cache ? readClasses(ctx.reg, steps) : [];
|
|
96
|
+
const ttl = ctx.cache ? cacheTtl(ctx, steps, mods, classes) : null;
|
|
97
|
+
if (ttl !== null) {
|
|
98
|
+
const store = ctx.cache.store;
|
|
99
|
+
const who = ctx.cache.who();
|
|
100
|
+
const key = store.key(q.text, q.params, who);
|
|
101
|
+
const hit = store.get(key);
|
|
102
|
+
if (hit)
|
|
103
|
+
return hit;
|
|
104
|
+
const tag = sessionTag(who.token);
|
|
105
|
+
const tok = store.token(classes, tag);
|
|
106
|
+
const t0 = performance.now();
|
|
107
|
+
const S = `"${ctx.schema.replace(/"/g, '""')}"`;
|
|
108
|
+
const [res, exp] = await ctx.exec.run((tx) => Promise.all([
|
|
109
|
+
tx.unsafe(q.text, q.params, { prepare: true }),
|
|
110
|
+
tx.unsafe(`select ${S}.session_expires() as e`),
|
|
111
|
+
]), { read: true });
|
|
112
|
+
report(ctx, mode, steps, t0, res.length);
|
|
113
|
+
const e = exp[0]?.e;
|
|
114
|
+
store.set(key, [...res], classes, tag, tok, ttl, e ? Date.parse(e) : null);
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
const t0 = performance.now();
|
|
118
|
+
const res = await ctx.exec.run((tx) => tx.unsafe(q.text, q.params, { prepare: true }), { read: true });
|
|
119
|
+
report(ctx, mode, steps, t0, res.length);
|
|
120
|
+
return res;
|
|
121
|
+
}
|
|
122
|
+
async function read(ctx, steps, mods, mode, agg) {
|
|
123
|
+
const hasOps = steps.some((s) => s.op);
|
|
124
|
+
if (ctx.batch && hasOps)
|
|
125
|
+
throw new LetopisError('invalid_query', 'план стоит в очереди батча — его исполнит batch.run()');
|
|
126
|
+
if (mods.forUpdate)
|
|
127
|
+
return lockedRead(ctx, steps, mods, mode);
|
|
128
|
+
if (hasOps)
|
|
129
|
+
return plan(ctx, steps, mods, mode, agg);
|
|
130
|
+
const { cache: _c, ...plain } = mods;
|
|
131
|
+
const q = buildRead(ctx.reg, ctx.schema, steps, plain, mode, agg);
|
|
132
|
+
const ev = mode === 'countPaths' ? 'count' : mode;
|
|
133
|
+
return exec(ctx, q, ev, steps, mods).then((res) => ({ q, res }));
|
|
121
134
|
}
|
|
122
|
-
const NO_NODES = new Map();
|
|
123
135
|
/**
|
|
124
|
-
*
|
|
125
|
-
* (
|
|
136
|
+
* Цепочка с глаголами: весь план одной транзакцией (§2.5). Результат приводится к виду ответа
|
|
137
|
+
* чтения (строки rows/paths, id, n, агрегаты), чтобы терминалы разбирали его одинаково.
|
|
126
138
|
*/
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
async function plan(ctx, steps, mods, mode, agg) {
|
|
140
|
+
precheckPlan(ctx.reg, steps);
|
|
141
|
+
const t0 = performance.now();
|
|
142
|
+
const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, steps, mods, mode, agg));
|
|
143
|
+
if (r.kind === 'read') {
|
|
144
|
+
report(ctx, mode === 'countPaths' ? 'count' : mode, steps, t0, r.res.length);
|
|
145
|
+
if (r.q === null) {
|
|
146
|
+
if (mode === 'count' || mode === 'countPaths')
|
|
147
|
+
return { q: null, res: [{ n: 0 }] };
|
|
148
|
+
if (mode === 'agg')
|
|
149
|
+
return { q: null, res: agg?.fn === 'countBy' ? [] : [{ v: null }] };
|
|
150
|
+
}
|
|
151
|
+
return r;
|
|
152
|
+
}
|
|
153
|
+
const rows = r.rows;
|
|
154
|
+
report(ctx, mode === 'countPaths' ? 'count' : mode === 'paths' ? 'paths' : mode === 'ids' ? 'ids' : 'rows', steps, t0, rows.length);
|
|
155
|
+
// не switch: check-docs принимает case-метки этого файла за имена, которые Proxy разбирает раньше классов
|
|
156
|
+
if (mode === 'paths')
|
|
157
|
+
return { q: null, res: rows.map((row) => ({ path: { [r.key]: row } })) };
|
|
158
|
+
if (mode === 'ids')
|
|
159
|
+
return { q: null, res: rows.map((row) => ({ id: row.id })) };
|
|
160
|
+
if (mode === 'count' || mode === 'countPaths')
|
|
161
|
+
return { q: null, res: [{ n: rows.length }] };
|
|
162
|
+
return { q: null, res: (mods.limit !== undefined ? rows.slice(0, mods.limit) : rows).map((row) => ({ row, done: true })) };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Чтение с блокировкой (§2.5, этап 4, п. 9): строки последнего шага блокируются for no key update
|
|
166
|
+
* в порядке id до конца явной транзакции. Сначала id шага, затем блокировка (политики правки RLS
|
|
167
|
+
* не дают заблокировать то, что править нельзя), затем то же чтение по свежим строкам: строка,
|
|
168
|
+
* которая перестала подходить под условия шага, в ответ не попадает; подходящая, но не
|
|
169
|
+
* заблокированная — acl_denied.
|
|
170
|
+
*/
|
|
171
|
+
async function lockedRead(ctx, steps, mods, mode) {
|
|
172
|
+
if (!ctx.exec.explicit)
|
|
173
|
+
throw new LetopisError('tx_required', 'forUpdate() — только внутри db.begin(): вне явной транзакции блокировка снялась бы сразу');
|
|
174
|
+
if ((mode !== 'rows' && mode !== 'ids') || mods.asOf !== undefined || mods.withDeleted || steps.some((s) => s.deepMax !== undefined || s.op)) {
|
|
175
|
+
throw new LetopisError('lock_unsupported', 'forUpdate() — с терминалами rows, first, ids, без агрегатов, asOf, versions, withDeleted, deep и глаголов');
|
|
176
|
+
}
|
|
177
|
+
const S = `"${ctx.schema.replace(/"/g, '""')}"`;
|
|
178
|
+
const { forUpdate: _f, ...plain } = mods;
|
|
179
|
+
const idsQ = buildRead(ctx.reg, ctx.schema, steps, plain, 'ids');
|
|
180
|
+
const { limit: _l, offset: _o, after: _a, ...unbounded } = plain;
|
|
181
|
+
const rowsQ = buildRead(ctx.reg, ctx.schema, steps, unbounded, 'rows');
|
|
182
|
+
const t0 = performance.now();
|
|
183
|
+
const res = await ctx.exec.run(async (q) => {
|
|
184
|
+
const ids = (await q.unsafe(idsQ.text, idsQ.params, { prepare: true })).map((r) => r.id);
|
|
185
|
+
if (!ids.length)
|
|
186
|
+
return [];
|
|
187
|
+
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));
|
|
188
|
+
const want = new Set(ids);
|
|
189
|
+
const fresh = (await q.unsafe(rowsQ.text, rowsQ.params, { prepare: true }))
|
|
190
|
+
.filter((r) => want.has(r.row.id));
|
|
191
|
+
const denied = fresh.filter((r) => !locked.has(r.row.id)).map((r) => r.row.id);
|
|
192
|
+
if (denied.length)
|
|
193
|
+
throw new LetopisError('acl_denied', `строки видны, но заблокировать их нельзя: ${denied.join(', ')}`, { ids: denied });
|
|
194
|
+
return fresh;
|
|
195
|
+
});
|
|
196
|
+
report(ctx, mode, steps, t0, res.length);
|
|
197
|
+
return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
|
|
198
|
+
}
|
|
199
|
+
/** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
|
|
200
|
+
const rowOf = (r) => (r.done ? r.row : toRow(r.row));
|
|
201
|
+
/** Значение слота: id, список id, строка ответа или ленивая цепочка. */
|
|
202
|
+
function slotValue(v) {
|
|
203
|
+
const peek = peekPlan(v);
|
|
204
|
+
if (peek)
|
|
205
|
+
return { plan: peek.steps, mods: peek.mods };
|
|
206
|
+
if (v === null || typeof v === 'string')
|
|
207
|
+
return v;
|
|
208
|
+
if (Array.isArray(v))
|
|
209
|
+
return v.map((x) => (typeof x === 'string' ? x : x.id));
|
|
210
|
+
if (typeof v === 'object' && typeof v.id === 'string')
|
|
211
|
+
return { id: v.id };
|
|
212
|
+
throw new LetopisError('invalid_query', 'значение слота — id, список id, строка ответа или цепочка');
|
|
213
|
+
}
|
|
214
|
+
function classProp(ctx, name, found, ownerSteps, mods, nodes) {
|
|
215
|
+
const { cls, role } = found;
|
|
136
216
|
const step = (filter) => {
|
|
137
217
|
const prev = ownerSteps?.[ownerSteps.length - 1];
|
|
138
|
-
// повтор
|
|
139
|
-
const node = prev && cls.
|
|
218
|
+
// повтор связи в пути — возврат к её узлу
|
|
219
|
+
const node = prev && cls.kind === 'link' && !role ? pivotNodeOf(ownerSteps, cls.name) : undefined;
|
|
140
220
|
if (node) {
|
|
141
|
-
|
|
142
|
-
return makeChain(ctx, [...ownerSteps, pivot], mods, batch, nodes);
|
|
221
|
+
return makeChain(ctx, [...ownerSteps, { name, cls, filter: normalizeFilter(filter), pivotKey: node.nodeKey }], mods, nodes);
|
|
143
222
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {},
|
|
223
|
+
const next = { name, cls, role, filter: normalizeFilter(filter), nodeKey: nextNodeKey() };
|
|
224
|
+
if (prev)
|
|
225
|
+
resolveHop(ctx.reg, nodeOf(ownerSteps, prev), next); // ранняя проверка пути
|
|
226
|
+
return makeChain(ctx, [...(ownerSteps ?? []), next], ownerSteps ? mods : {}, nodes);
|
|
148
227
|
};
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
228
|
+
// слот: .Роль.set(x) / .unset() / .add(x) / .remove(x) — концы версии, которую пишет глагол
|
|
229
|
+
const slot = (op) => (v) => {
|
|
230
|
+
const last = ownerSteps?.[ownerSteps.length - 1];
|
|
231
|
+
const kind = last?.op?.kind;
|
|
232
|
+
if (!last || (kind !== 'create' && kind !== 'update' && kind !== 'upsert')) {
|
|
233
|
+
throw new LetopisError('invalid_query', `слот .${name}.${op}() — после create(), update() или upsert(): db.Класс(…).update(…).${name}.${op}(…)`);
|
|
154
234
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
235
|
+
const role = slotRole(ctx.reg, last.cls, name);
|
|
236
|
+
const end = last.cls.ends[role];
|
|
237
|
+
if ((op === 'add' || op === 'remove') && !end.many)
|
|
238
|
+
throw new LetopisError('invalid_query', `слот .${name}.${op}(): конец ${role} одиночный — set() или unset()`);
|
|
239
|
+
if (op === 'unset' && (end.required || (end.min ?? 0) > 0))
|
|
240
|
+
throw new LetopisError('invalid_query', `слот .${name}.unset(): конец ${role} обязателен`);
|
|
241
|
+
if ((op === 'set' || op === 'unset') && last.slots?.some((x) => x.role === role && (x.op === 'set' || x.op === 'unset'))) {
|
|
242
|
+
throw new LetopisError('invalid_query', `слот ${role} задан дважды в одной записи`);
|
|
158
243
|
}
|
|
159
|
-
|
|
244
|
+
const value = op === 'unset' ? null : slotValue(v);
|
|
245
|
+
const next = ownerSteps.slice();
|
|
246
|
+
next[next.length - 1] = { ...last, slots: [...(last.slots ?? []), { role, op, value }] };
|
|
247
|
+
if (ctx.batch?.entry)
|
|
248
|
+
ctx.batch.entry.steps = next;
|
|
249
|
+
return makeChain(ctx, next, mods, nodes);
|
|
160
250
|
};
|
|
161
251
|
return new Proxy(step, {
|
|
162
|
-
apply: (t, _self, args) => t(...args),
|
|
163
252
|
get(t, p, r) {
|
|
164
|
-
if (p === 'set')
|
|
165
|
-
return (
|
|
166
|
-
|
|
167
|
-
return applySlot(cls, slotValue(cls, v));
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
if (p === 'unset') {
|
|
171
|
-
return () => {
|
|
172
|
-
const ownerCls = slotGuard();
|
|
173
|
-
const end = findEnd(ownerCls, cls);
|
|
174
|
-
if (ownerCls.strictEnds && !end.optional) {
|
|
175
|
-
throw new Error(`letopis: link end "${cls.id}" of "${ownerCls.id}" is required — cannot unset`);
|
|
176
|
-
}
|
|
177
|
-
return applySlot(cls, null);
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
if (p === 'delete') {
|
|
181
|
-
return () => {
|
|
182
|
-
throw new Error(`letopis: slot .delete() renamed to .unset() (0.16.0) — .${name}.unset() removes the link end`);
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
if (typeof p === 'symbol' || p === 'then' || p in Function.prototype || p === 'name' || p === 'length' || p === 'prototype') {
|
|
186
|
-
return Reflect.get(t, p, r);
|
|
187
|
-
}
|
|
188
|
-
throw new Error(`letopis: "${name}" without call is a link slot (set/unset); for navigation call ${name}(…)`);
|
|
253
|
+
if (p === 'set' || p === 'unset' || p === 'add' || p === 'remove')
|
|
254
|
+
return slot(p);
|
|
255
|
+
return Reflect.get(t, p, r);
|
|
189
256
|
},
|
|
190
257
|
});
|
|
191
|
-
// END_BLOCK_LINK_SLOT
|
|
192
258
|
}
|
|
193
|
-
|
|
194
|
-
// PURPOSE: Собирает Proxy-цепочку над списком шагов — модификаторы и слоты порождают новые цепочки, терминалы делегируют чтение (M-SQL) или исполнение плана (M-WRITE.runPlan).
|
|
195
|
-
// INPUTS: { ctx: Ctx; steps: Step[] - накопленные шаги; mods: ChainMods; batch?: BatchRef - контекст батча; nodes?: NodeMap - entity-идентичность }
|
|
196
|
-
// OUTPUTS: { Chain - Proxy с терминалами/модификаторами и свойствами-классами }
|
|
197
|
-
// SIDE_EFFECTS: терминалы читают/пишут БД (runPaths/runRows/runQuery, runPlan); в батче мутирует очередь через syncBatch
|
|
198
|
-
// LINKS: M-CHAIN, V-M-CHAIN
|
|
199
|
-
// END_CONTRACT: makeChain
|
|
200
|
-
function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
201
|
-
const hasOps = steps.some((s) => s.op);
|
|
202
|
-
// START_BLOCK_STEP_MUTATORS
|
|
259
|
+
export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
203
260
|
const withLast = (patch) => {
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
throw new
|
|
207
|
-
}
|
|
261
|
+
const lastOp = steps[steps.length - 1]?.op;
|
|
262
|
+
if (lastOp)
|
|
263
|
+
throw new LetopisError('invalid_query', `модификатор шага после ${lastOp.kind}() — сначала шаг класса`);
|
|
208
264
|
const next = steps.slice();
|
|
209
|
-
next[next.length - 1] = { ...
|
|
210
|
-
return makeChain(ctx, next, mods,
|
|
265
|
+
next[next.length - 1] = { ...steps[steps.length - 1], ...patch };
|
|
266
|
+
return makeChain(ctx, next, mods, nodes);
|
|
211
267
|
};
|
|
212
|
-
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch },
|
|
213
|
-
|
|
214
|
-
// START_BLOCK_WRITE_DISPATCH
|
|
215
|
-
/** Обновить план в батче (регистрируется на первой операции, дальше мутируется). */
|
|
268
|
+
const withMods = (patch) => makeChain(ctx, steps, { ...mods, ...patch }, nodes);
|
|
269
|
+
/** Батч: первый глагол ставит план в очередь, следующие звенья обновляют его. */
|
|
216
270
|
const syncBatch = (next) => {
|
|
217
|
-
|
|
271
|
+
const b = ctx.batch;
|
|
272
|
+
if (!b)
|
|
218
273
|
return;
|
|
219
|
-
if (
|
|
220
|
-
|
|
274
|
+
if (b.entry)
|
|
275
|
+
b.entry.steps = next;
|
|
221
276
|
else {
|
|
222
|
-
|
|
223
|
-
|
|
277
|
+
b.entry = { steps: next };
|
|
278
|
+
b.queue.push(b.entry);
|
|
224
279
|
}
|
|
225
280
|
};
|
|
226
|
-
/**
|
|
281
|
+
/** Глагол — звено плана на последнем шаге (глагол уже есть — новый шаг «те же сущности»); модификаторы — его целям. */
|
|
227
282
|
const withOp = (op) => {
|
|
228
283
|
const last = steps[steps.length - 1];
|
|
284
|
+
if (!last)
|
|
285
|
+
throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
|
|
229
286
|
const next = last.op
|
|
230
|
-
? [...steps, { name: last.name, cls: last.cls, self: true, op, nodeKey: nextNodeKey() }]
|
|
231
|
-
: [...steps.slice(0, -1), { ...last, op }];
|
|
232
|
-
syncBatch(next);
|
|
233
|
-
return makeChain(ctx, next, {}, batch, nodes);
|
|
234
|
-
};
|
|
235
|
-
/**
|
|
236
|
-
* Слот связи: значение конца ЗАПИСЫВАЕМОЙ версии последнего шага — только после
|
|
237
|
-
* операции записи (…create(…).Класс.set(x) / …update(…).Класс.unset()): та же версия.
|
|
238
|
-
*/
|
|
239
|
-
const applySlot = (slotCls, value) => {
|
|
240
|
-
const last = steps[steps.length - 1];
|
|
241
|
-
if (!last.op) {
|
|
242
|
-
throw new Error(`letopis: link slot "${slotCls.alias}" needs a write — add .create(…)/.update(…) before .${slotCls.alias}.${value === null ? 'unset()' : 'set(…)'}`);
|
|
243
|
-
}
|
|
244
|
-
if (last.op.kind !== 'create' && last.op.kind !== 'update') {
|
|
245
|
-
throw new Error(`letopis: link slot after ${last.op.kind}() — slots apply to create()/update()`);
|
|
246
|
-
}
|
|
247
|
-
if (last.extraLinks && slotCls.id in last.extraLinks) {
|
|
248
|
-
throw new Error(`letopis: duplicate link slot "${slotCls.id}" in one write`);
|
|
249
|
-
}
|
|
250
|
-
const patched = {
|
|
251
|
-
...last,
|
|
252
|
-
extraLinks: { ...(last.extraLinks ?? {}), [slotCls.id]: value },
|
|
253
|
-
};
|
|
254
|
-
const next = [...steps.slice(0, -1), patched];
|
|
287
|
+
? [...steps, { name: last.name, cls: last.cls, role: last.role, aliasKey: last.aliasKey, self: true, op, opMods: mods, nodeKey: nextNodeKey() }]
|
|
288
|
+
: [...steps.slice(0, -1), { ...last, op, opMods: mods }];
|
|
255
289
|
syncBatch(next);
|
|
256
|
-
return makeChain(ctx, next,
|
|
290
|
+
return makeChain(ctx, next, {}, nodes);
|
|
257
291
|
};
|
|
258
|
-
// END_BLOCK_WRITE_DISPATCH
|
|
259
|
-
// START_BLOCK_ENTITY_EMBED
|
|
260
|
-
/** entity(x): вклейка узла/паттерна; та же переменная повторно — возврат к её узлу. */
|
|
261
292
|
const entityStep = (x) => {
|
|
262
293
|
const plan = peekPlan(x);
|
|
294
|
+
const prev = steps.length ? nodeOf(steps, steps[steps.length - 1]) : undefined;
|
|
295
|
+
if (plan?.steps.some((s) => s.op))
|
|
296
|
+
throw new LetopisError('invalid_query', 'entity() принимает читающий паттерн — цепочку с глаголами вклеить нельзя');
|
|
263
297
|
if (plan) {
|
|
264
298
|
const known = nodes.get(x);
|
|
265
299
|
if (known !== undefined) {
|
|
266
300
|
const lastX = plan.steps[plan.steps.length - 1];
|
|
267
|
-
|
|
268
|
-
return makeChain(ctx, [...steps, pivot], mods, batch, nodes);
|
|
301
|
+
return makeChain(ctx, [...steps, { name: lastX.name, cls: lastX.cls, pivotKey: known }], mods, nodes);
|
|
269
302
|
}
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
const prev = steps[steps.length - 1];
|
|
274
|
-
if (prev && !prev.op)
|
|
275
|
-
resolveHop(prev.cls, plan.steps[0].cls); // стык по правилам пути
|
|
303
|
+
if (prev)
|
|
304
|
+
resolveHop(ctx.reg, prev, plan.steps[0]);
|
|
276
305
|
const nodeKey = plan.steps[plan.steps.length - 1].nodeKey;
|
|
277
306
|
const nextNodes = new Map(nodes);
|
|
278
307
|
nextNodes.set(x, nodeKey);
|
|
279
|
-
return makeChain(ctx, [...steps, ...plan.steps], mods,
|
|
308
|
+
return makeChain(ctx, [...steps, ...plan.steps], mods, nextNodes);
|
|
280
309
|
}
|
|
281
310
|
if (x !== null && typeof x === 'object' && typeof x.id === 'string' && typeof x.class === 'string') {
|
|
282
311
|
const row = x;
|
|
283
|
-
const cls = ctx.
|
|
284
|
-
const
|
|
285
|
-
if (prev
|
|
286
|
-
resolveHop(
|
|
287
|
-
|
|
288
|
-
return makeChain(ctx, [...steps, next], mods, batch, nodes);
|
|
312
|
+
const cls = ctx.reg.resolve(row.class);
|
|
313
|
+
const next = { name: cls.name, cls, filter: row.id, nodeKey: nextNodeKey(), exact: true };
|
|
314
|
+
if (prev)
|
|
315
|
+
resolveHop(ctx.reg, prev, next);
|
|
316
|
+
return makeChain(ctx, [...steps, next], mods, nodes);
|
|
289
317
|
}
|
|
290
|
-
throw new
|
|
318
|
+
throw new LetopisError('invalid_query', 'entity() принимает ленивую цепочку или строку ответа');
|
|
291
319
|
};
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
const agg = (fn) => async (field) => {
|
|
321
|
+
const { q, res } = await read(ctx, steps, mods, 'agg', { fn, field });
|
|
322
|
+
if (fn === 'countBy')
|
|
323
|
+
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
324
|
+
const v = res[0]?.v;
|
|
325
|
+
if (v === null || v === undefined)
|
|
326
|
+
return null;
|
|
327
|
+
if (typeof v !== 'string')
|
|
328
|
+
return v;
|
|
329
|
+
if (fn === 'sum' || fn === 'avg' || q?.aggLeaf === 'numeric')
|
|
330
|
+
return Number(v);
|
|
331
|
+
return v;
|
|
298
332
|
};
|
|
299
333
|
const handler = {
|
|
300
334
|
get(_t, prop) {
|
|
301
335
|
if (prop === PLAN)
|
|
302
336
|
return { steps, mods };
|
|
303
|
-
if (typeof prop === 'symbol'
|
|
337
|
+
if (typeof prop === 'symbol')
|
|
304
338
|
return undefined;
|
|
305
339
|
switch (prop) {
|
|
306
|
-
|
|
340
|
+
case 'then':
|
|
341
|
+
return undefined;
|
|
307
342
|
case 'entity':
|
|
308
343
|
return entityStep;
|
|
309
|
-
case '
|
|
310
|
-
return () => {
|
|
311
|
-
|
|
312
|
-
|
|
344
|
+
case 'paths':
|
|
345
|
+
return async () => {
|
|
346
|
+
// q === null — пути из результата глагола: строки уже готовы
|
|
347
|
+
const { q, res } = await read(ctx, steps, mods, 'paths');
|
|
348
|
+
return res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, q === null ? v : toRow(v)])));
|
|
313
349
|
};
|
|
314
|
-
case 'execute':
|
|
315
|
-
throw new Error('letopis: execute() renamed to run() (0.11.0)');
|
|
316
350
|
case 'rows':
|
|
317
|
-
return () =>
|
|
318
|
-
guardBatch();
|
|
319
|
-
return hasOps ? runPlan(ctx, steps, mods, 'rows') : runRows(ctx, steps, mods);
|
|
320
|
-
};
|
|
351
|
+
return async () => (await read(ctx, steps, mods, 'rows')).res.map(rowOf);
|
|
321
352
|
case 'first':
|
|
322
353
|
return async () => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
? (await runPlan(ctx, steps, mods, 'rows'))
|
|
326
|
-
: await runRows(ctx, steps, { ...mods, limit: 1 });
|
|
327
|
-
return rows[0] ?? null;
|
|
354
|
+
const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows');
|
|
355
|
+
return res[0] ? rowOf(res[0]) : null;
|
|
328
356
|
};
|
|
329
357
|
case 'ids':
|
|
330
|
-
return async () =>
|
|
331
|
-
guardBatch();
|
|
332
|
-
if (hasOps)
|
|
333
|
-
return runPlan(ctx, steps, mods, 'ids');
|
|
334
|
-
const q = buildRead(ctx, steps, mods, 'ids');
|
|
335
|
-
const res = await runQuery(ctx, q.text, q.params, 'ids', clsOf(steps));
|
|
336
|
-
return res.map((r) => r.id);
|
|
337
|
-
};
|
|
358
|
+
return async () => (await read(ctx, steps, mods, 'ids')).res.map((r) => r.id);
|
|
338
359
|
case 'count':
|
|
339
|
-
return async () =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
return res[0].n;
|
|
360
|
+
return async (opts) => (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count')).res[0].n;
|
|
361
|
+
case 'versions':
|
|
362
|
+
return async (opts) => {
|
|
363
|
+
warnNoHistory(steps, 'versions');
|
|
364
|
+
const m = opts?.follow ? { ...mods, follow: true } : mods;
|
|
365
|
+
return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
|
|
346
366
|
};
|
|
347
|
-
|
|
348
|
-
|
|
367
|
+
case 'sum':
|
|
368
|
+
case 'avg':
|
|
369
|
+
case 'min':
|
|
370
|
+
case 'max':
|
|
371
|
+
case 'countBy':
|
|
372
|
+
return agg(prop);
|
|
349
373
|
case 'limit':
|
|
350
374
|
return (n) => withMods({ limit: n });
|
|
351
375
|
case 'offset':
|
|
@@ -353,313 +377,107 @@ function makeChain(ctx, steps, mods, batch, nodes = NO_NODES) {
|
|
|
353
377
|
case 'sort':
|
|
354
378
|
return (field, dir) => withMods({ order: field, desc: dir === true || dir === 'desc' });
|
|
355
379
|
case 'asOf':
|
|
356
|
-
return (t) =>
|
|
380
|
+
return (t) => {
|
|
381
|
+
warnNoHistory(steps, 'asOf');
|
|
382
|
+
return withMods({ asOf: t instanceof Date ? t.toISOString() : t });
|
|
383
|
+
};
|
|
384
|
+
case 'after':
|
|
385
|
+
return (cursor) => withMods({ after: cursor });
|
|
357
386
|
case 'withDeleted':
|
|
358
387
|
return () => withMods({ withDeleted: true });
|
|
359
388
|
case 'deep':
|
|
360
389
|
return (max = 32) => withLast({ deepMax: max });
|
|
361
390
|
case 'exact':
|
|
362
|
-
|
|
363
|
-
return () => withLast({ exactClass: true });
|
|
364
|
-
// END_BLOCK_READ_MODIFIERS
|
|
365
|
-
// START_BLOCK_AGGREGATIONS
|
|
366
|
-
case 'sum':
|
|
367
|
-
case 'avg':
|
|
368
|
-
case 'min':
|
|
369
|
-
case 'max':
|
|
370
|
-
return async (field) => {
|
|
371
|
-
guardBatch();
|
|
372
|
-
const aggMods = { ...mods, aggFn: prop, aggField: field };
|
|
373
|
-
const res = hasOps
|
|
374
|
-
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
375
|
-
: await (async () => {
|
|
376
|
-
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
377
|
-
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
378
|
-
})();
|
|
379
|
-
const v = res[0]?.v;
|
|
380
|
-
if (v === null || v === undefined)
|
|
381
|
-
return null;
|
|
382
|
-
if (typeof v !== 'string')
|
|
383
|
-
return v;
|
|
384
|
-
if (prop === 'sum' || prop === 'avg')
|
|
385
|
-
return Number(v);
|
|
386
|
-
// min/max: postgres.js отдаёт numeric строкой — приводим, если лист числовой по Schema
|
|
387
|
-
const leaf = leafType(steps[steps.length - 1].cls, field.slice(5).split('.'));
|
|
388
|
-
return leaf?.kind === 'number' ? Number(v) : v;
|
|
389
|
-
};
|
|
390
|
-
case 'countBy':
|
|
391
|
-
return async (field) => {
|
|
392
|
-
guardBatch();
|
|
393
|
-
const aggMods = { ...mods, aggFn: 'countBy', aggField: field };
|
|
394
|
-
const res = hasOps
|
|
395
|
-
? (await runPlan(ctx, steps, aggMods, 'agg'))
|
|
396
|
-
: await (async () => {
|
|
397
|
-
const q = buildRead(ctx, steps, aggMods, 'agg');
|
|
398
|
-
return runQuery(ctx, q.text, q.params, 'agg', clsOf(steps));
|
|
399
|
-
})();
|
|
400
|
-
return Object.fromEntries(res.map((r) => [r.k ?? 'null', r.n]));
|
|
401
|
-
};
|
|
402
|
-
// END_BLOCK_AGGREGATIONS
|
|
403
|
-
// START_BLOCK_VERSIONS_CURSOR
|
|
404
|
-
case 'after':
|
|
405
|
-
return (cursor) => withMods({ after: cursor });
|
|
406
|
-
case 'versions':
|
|
407
|
-
return async () => {
|
|
408
|
-
guardBatch();
|
|
409
|
-
if (hasOps)
|
|
410
|
-
return runPlan(ctx, steps, mods, 'versions');
|
|
411
|
-
const q = buildRead(ctx, steps, mods, 'versions');
|
|
412
|
-
const res = await runQuery(ctx, q.text, q.params, 'versions', clsOf(steps));
|
|
413
|
-
return res.map((r) => {
|
|
414
|
-
const raw = r.row;
|
|
415
|
-
const row = toRow(r.row);
|
|
416
|
-
return raw.deleted ? { ...row, $deleted: true } : row;
|
|
417
|
-
});
|
|
418
|
-
};
|
|
419
|
-
// END_BLOCK_VERSIONS_CURSOR
|
|
420
|
-
// START_BLOCK_WRITE_TERMINALS
|
|
421
|
-
case 'create':
|
|
422
|
-
return (data = {}) => {
|
|
423
|
-
const last = steps[steps.length - 1];
|
|
424
|
-
if (last && !last.op) { // после op create пишет результат — фильтр там подставит план
|
|
425
|
-
if (last.pivotKey !== undefined) {
|
|
426
|
-
throw new Error('letopis: create() on a pivot step — pivot returns to an existing node, use update()');
|
|
427
|
-
}
|
|
428
|
-
if (last.filter !== undefined && typeof last.filter !== 'string') {
|
|
429
|
-
throw new Error(`letopis: create() takes no filter — ${last.name}(id).create(…) fixes the id, searching is update()`);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return withOp({ kind: 'create', data, mods });
|
|
433
|
-
};
|
|
434
|
-
case 'update':
|
|
435
|
-
return (data = {}) => withOp({ kind: 'update', data, mods });
|
|
436
|
-
case 'set':
|
|
437
|
-
throw new Error('letopis: set() split into create()/update() (0.16.0) — create() inserts, update() versions what the path finds');
|
|
438
|
-
case 'delete':
|
|
439
|
-
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, mods });
|
|
440
|
-
case 'anonymize':
|
|
441
|
-
return (fields) => withOp({ kind: 'anonymize', fields, mods });
|
|
442
|
-
case 'purge':
|
|
443
|
-
return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, mods });
|
|
444
|
-
// END_BLOCK_WRITE_TERMINALS
|
|
445
|
-
// START_BLOCK_COLUMN_MODS
|
|
391
|
+
return () => withLast({ exact: true });
|
|
446
392
|
case 'alias':
|
|
447
393
|
return (name) => withLast({ aliasKey: name });
|
|
448
394
|
case 'tags':
|
|
449
395
|
return (v) => withLast({ tagsFilter: v });
|
|
450
|
-
case 'account':
|
|
451
|
-
return (v) => withLast({ accountFilter: typeof v === 'object' ? v.id : v });
|
|
452
396
|
case 'owner':
|
|
453
397
|
return (v) => withLast({ ownerFilter: typeof v === 'object' ? v.id : v });
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
398
|
+
// охранники снесённого API (этап 3, п. 11)
|
|
399
|
+
case 'run':
|
|
400
|
+
throw removed('run()', 'paths()');
|
|
401
|
+
case 'execute':
|
|
402
|
+
throw removed('execute()', 'paths()');
|
|
403
|
+
case 'account':
|
|
404
|
+
throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
|
|
405
|
+
// глаголы записи — звенья плана (этап 4)
|
|
406
|
+
case 'create':
|
|
407
|
+
return (data = {}) => withOp({ kind: 'create', data });
|
|
408
|
+
case 'update':
|
|
409
|
+
return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}) });
|
|
410
|
+
case 'upsert':
|
|
411
|
+
return (data = {}) => withOp({ kind: 'upsert', data });
|
|
412
|
+
case 'delete':
|
|
413
|
+
return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true });
|
|
414
|
+
case 'anonymize':
|
|
415
|
+
return (fields) => withOp({ kind: 'anonymize', fields });
|
|
416
|
+
case 'reclass':
|
|
417
|
+
return (cls, data) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}) });
|
|
418
|
+
case 'set':
|
|
419
|
+
case 'unset':
|
|
420
|
+
case 'add':
|
|
421
|
+
case 'remove':
|
|
422
|
+
throw new LetopisError('invalid_query', `${prop}() — метод слота: db.Класс(…).update(…).Роль.${prop}(…)`);
|
|
423
|
+
case 'forUpdate':
|
|
424
|
+
return () => withMods({ forUpdate: true });
|
|
425
|
+
case 'restore':
|
|
426
|
+
return () => withOp({ kind: 'restore' });
|
|
427
|
+
case 'purge':
|
|
428
|
+
return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true });
|
|
429
|
+
case 'rekey':
|
|
430
|
+
return (patch) => withOp({ kind: 'rekey', data: patch ?? {} });
|
|
431
|
+
case 'cache':
|
|
432
|
+
return (opts) => {
|
|
433
|
+
if (opts?.ttl !== undefined && !(opts.ttl > 0))
|
|
434
|
+
throw new LetopisError('invalid_query', 'cache({ ttl }): срок — положительное число секунд');
|
|
435
|
+
return withMods({ cache: opts ?? {} });
|
|
436
|
+
};
|
|
463
437
|
}
|
|
464
|
-
ctx.
|
|
465
|
-
return
|
|
466
|
-
// END_BLOCK_CLASS_RESOLVE
|
|
438
|
+
const found = lookupStep(ctx.reg, prop) ?? unknownName(prop);
|
|
439
|
+
return classProp(ctx, prop, found, steps, mods, nodes);
|
|
467
440
|
},
|
|
468
441
|
};
|
|
469
442
|
return new Proxy({}, handler);
|
|
470
443
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
function makeBatch(ctx, queue) {
|
|
479
|
-
const handler = {
|
|
444
|
+
/**
|
|
445
|
+
* Батч: очередь планов; каждый вызов класса — своя ссылка на очередь (свой план). run() исполняет очередь
|
|
446
|
+
* в контексте ctx (актор, арендатор, владелец, транзакция) — поэтому очередь принадлежит фасаду этого ctx
|
|
447
|
+
* и общей с другими фасадами быть не может: иначе план исполнился бы от имени чужого актора.
|
|
448
|
+
*/
|
|
449
|
+
export function makeBatch(ctx, queue) {
|
|
450
|
+
return new Proxy({}, {
|
|
480
451
|
get(_t, prop) {
|
|
481
452
|
if (typeof prop === 'symbol' || prop === 'then')
|
|
482
453
|
return undefined;
|
|
483
454
|
switch (prop) {
|
|
484
|
-
// START_BLOCK_BATCH_QUEUE
|
|
485
455
|
case 'run':
|
|
486
456
|
return async () => {
|
|
487
|
-
const plans = queue.splice(0, queue.length);
|
|
488
|
-
|
|
457
|
+
const plans = queue.splice(0, queue.length).map((x) => x.steps);
|
|
458
|
+
for (const p of plans)
|
|
459
|
+
precheckPlan(ctx.reg, p);
|
|
460
|
+
if (!plans.length)
|
|
461
|
+
return [];
|
|
462
|
+
return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, plans));
|
|
489
463
|
};
|
|
490
464
|
case 'execute':
|
|
491
|
-
throw
|
|
465
|
+
throw removed('batch.execute()', 'batch.run()');
|
|
492
466
|
case 'discard':
|
|
493
467
|
return () => void queue.splice(0, queue.length);
|
|
494
468
|
case 'size':
|
|
495
469
|
return () => queue.length;
|
|
496
|
-
// END_BLOCK_BATCH_QUEUE
|
|
497
470
|
}
|
|
498
|
-
|
|
499
|
-
return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, { queue }, NO_NODES);
|
|
500
|
-
}
|
|
501
|
-
ctx.registry.resolve(prop);
|
|
502
|
-
return undefined;
|
|
471
|
+
return startStep({ ...ctx, batch: { queue } }, prop);
|
|
503
472
|
},
|
|
504
|
-
};
|
|
505
|
-
return new Proxy({}, handler);
|
|
473
|
+
});
|
|
506
474
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const state = root ?? { batches: new Map() };
|
|
516
|
-
let tables; // лениво, на ctx этого фасада (работает и в tr)
|
|
517
|
-
let auth;
|
|
518
|
-
let acl;
|
|
519
|
-
const handler = {
|
|
520
|
-
get(_t, prop) {
|
|
521
|
-
if (typeof prop === 'symbol' || prop === 'then')
|
|
522
|
-
return undefined;
|
|
523
|
-
// START_BLOCK_TABLES_AUTH_ACL
|
|
524
|
-
if (prop === 'accounts' || prop === 'credentials' || prop === 'resources' || prop === 'rules' || prop === 'schema') {
|
|
525
|
-
tables ??= makeTables(ctx);
|
|
526
|
-
return tables[prop];
|
|
527
|
-
}
|
|
528
|
-
if (prop === 'auth') {
|
|
529
|
-
tables ??= makeTables(ctx);
|
|
530
|
-
auth ??= makeAuth(tables);
|
|
531
|
-
return auth;
|
|
532
|
-
}
|
|
533
|
-
if (prop === 'acl') {
|
|
534
|
-
tables ??= makeTables(ctx);
|
|
535
|
-
acl ??= makeAcl(tables, ctx.registry);
|
|
536
|
-
return acl;
|
|
537
|
-
}
|
|
538
|
-
// END_BLOCK_TABLES_AUTH_ACL
|
|
539
|
-
switch (prop) {
|
|
540
|
-
// START_BLOCK_SCOPE_AS
|
|
541
|
-
// db.as(account): тот же пул, но вызов от имени этого арендатора. Дешёвый клон ctx
|
|
542
|
-
// (как beginTx), НЕ новое соединение. Батчи не наследуются: очередь планов, общая
|
|
543
|
-
// для разных арендаторов, — это утечка записи между ними.
|
|
544
|
-
case 'as':
|
|
545
|
-
return async (a, o) => {
|
|
546
|
-
const account = typeof a === 'object' ? a?.id : a;
|
|
547
|
-
if (!account)
|
|
548
|
-
throw new Error('letopis: db.as(account) requires an account id (uuid or { id })');
|
|
549
|
-
const scoped = { ...ctx, account, owner: o?.owner, aclDecide: undefined };
|
|
550
|
-
// reloadSchema() со scope: реестр перечитывает КОРЕНЬ (единый источник), затем
|
|
551
|
-
// scope подхватывает его и перекомпилирует свой энфорсер под своего субъекта.
|
|
552
|
-
scoped.reload = async () => {
|
|
553
|
-
await ctx.reload?.();
|
|
554
|
-
scoped.registry = ctx.registry;
|
|
555
|
-
await ctx.compileAcl?.(scoped);
|
|
556
|
-
};
|
|
557
|
-
await ctx.compileAcl?.(scoped);
|
|
558
|
-
return makeDb(scoped);
|
|
559
|
-
};
|
|
560
|
-
// END_BLOCK_SCOPE_AS
|
|
561
|
-
// START_BLOCK_TX_LOCK
|
|
562
|
-
case 'begin':
|
|
563
|
-
return async () => makeDb(await beginTx(ctx), state);
|
|
564
|
-
case 'commit':
|
|
565
|
-
return (tr) => {
|
|
566
|
-
if (tr)
|
|
567
|
-
return tr.commit();
|
|
568
|
-
const c = ctx;
|
|
569
|
-
if (typeof c.commit === 'function')
|
|
570
|
-
return c.commit();
|
|
571
|
-
throw new Error('letopis: commit() needs a transaction: db.commit(tr) or tr.commit()');
|
|
572
|
-
};
|
|
573
|
-
case 'rollback':
|
|
574
|
-
return (tr) => {
|
|
575
|
-
if (tr)
|
|
576
|
-
return tr.rollback();
|
|
577
|
-
const c = ctx;
|
|
578
|
-
if (typeof c.rollback === 'function')
|
|
579
|
-
return c.rollback();
|
|
580
|
-
throw new Error('letopis: rollback() needs a transaction: db.rollback(tr) or tr.rollback()');
|
|
581
|
-
};
|
|
582
|
-
case 'lock':
|
|
583
|
-
return (...keys) => lock(ctx, ...keys);
|
|
584
|
-
// END_BLOCK_TX_LOCK
|
|
585
|
-
// START_BLOCK_BATCH_OPEN
|
|
586
|
-
case 'batch':
|
|
587
|
-
return (name) => {
|
|
588
|
-
let q = state.batches.get(name);
|
|
589
|
-
if (!q) {
|
|
590
|
-
q = [];
|
|
591
|
-
state.batches.set(name, q);
|
|
592
|
-
}
|
|
593
|
-
return makeBatch(ctx, q);
|
|
594
|
-
};
|
|
595
|
-
// END_BLOCK_BATCH_OPEN
|
|
596
|
-
// START_BLOCK_WATCH_LISTEN
|
|
597
|
-
case 'watch':
|
|
598
|
-
return async (a, b, c) => {
|
|
599
|
-
const cls = typeof a === 'string' ? ctx.registry.resolve(a).id : undefined;
|
|
600
|
-
const cb = typeof a === 'function' ? a : b;
|
|
601
|
-
const opts = (typeof a === 'function' ? b : c) ?? {};
|
|
602
|
-
const listener = (payload) => {
|
|
603
|
-
const e = JSON.parse(payload);
|
|
604
|
-
if (e.partition !== ctx.partition)
|
|
605
|
-
return;
|
|
606
|
-
if (cls && e.class !== cls)
|
|
607
|
-
return;
|
|
608
|
-
// enforceAcl: отдаём только при БЕЗУСЛОВНОМ allow — payload не содержит
|
|
609
|
-
// owner/account/data, предикат строки проверить нечем (id — тоже утечка)
|
|
610
|
-
if (ctx.aclDecide) {
|
|
611
|
-
try {
|
|
612
|
-
const d = ctx.aclDecide(ctx.registry.resolve(e.class), 'READ');
|
|
613
|
-
if (!d.allow || d.filter)
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
catch {
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
cb(e);
|
|
621
|
-
};
|
|
622
|
-
// postgres.js: dedicated LISTEN-соединение само переподключается (backoff) и
|
|
623
|
-
// повторяет LISTEN; onlisten зовётся на первом connect И на каждом re-listen.
|
|
624
|
-
// NOTIFY за время разрыва потеряны — onReconnect даёт точку дочитать пропущенное.
|
|
625
|
-
let first = true;
|
|
626
|
-
const onlisten = () => {
|
|
627
|
-
if (first) {
|
|
628
|
-
first = false;
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
opts.onReconnect?.();
|
|
632
|
-
};
|
|
633
|
-
const { unlisten } = await ctx.sql.listen(ctx.pgSchema, listener, onlisten);
|
|
634
|
-
return unlisten;
|
|
635
|
-
};
|
|
636
|
-
// END_BLOCK_WATCH_LISTEN
|
|
637
|
-
// START_BLOCK_CLOSE_META
|
|
638
|
-
case 'close':
|
|
639
|
-
return () => ctx.sql.end();
|
|
640
|
-
case 'reloadSchema':
|
|
641
|
-
return async () => {
|
|
642
|
-
await ctx.reload?.();
|
|
643
|
-
acl = undefined; // db.acl-фасад перестроится на свежем registry при следующем доступе
|
|
644
|
-
};
|
|
645
|
-
case 'registry':
|
|
646
|
-
return ctx.registry;
|
|
647
|
-
case 'sql':
|
|
648
|
-
return ctx.sql;
|
|
649
|
-
// END_BLOCK_CLOSE_META
|
|
650
|
-
}
|
|
651
|
-
// START_BLOCK_ENTITY_START
|
|
652
|
-
if (prop === 'entity') {
|
|
653
|
-
// старт пути с готового паттерна/строки: db.entity(pos).…
|
|
654
|
-
return (x) => makeChain(ctx, [], {}, undefined, NO_NODES).entity(x);
|
|
655
|
-
}
|
|
656
|
-
if (ctx.registry.has(prop)) {
|
|
657
|
-
return classProp(ctx, String(prop), ctx.registry.resolve(prop), undefined, {}, undefined, NO_NODES);
|
|
658
|
-
}
|
|
659
|
-
ctx.registry.resolve(prop);
|
|
660
|
-
return undefined;
|
|
661
|
-
// END_BLOCK_ENTITY_START
|
|
662
|
-
},
|
|
663
|
-
};
|
|
664
|
-
return new Proxy({}, handler);
|
|
475
|
+
/** Старт пути от корня db: класс, алиас или роль. */
|
|
476
|
+
export function startStep(ctx, name) {
|
|
477
|
+
const found = lookupStep(ctx.reg, name) ?? unknownName(name);
|
|
478
|
+
return classProp(ctx, name, found, undefined, {}, NO_NODES);
|
|
479
|
+
}
|
|
480
|
+
/** Старт пути с готового узла: db.entity(row | цепочка). */
|
|
481
|
+
export function startEntity(ctx, x) {
|
|
482
|
+
return makeChain(ctx, [], {}, NO_NODES).entity(x);
|
|
665
483
|
}
|