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