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/write.js
CHANGED
|
@@ -1,817 +1,755 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert, delete, anonymize
|
|
3
|
+
* и reclass — звенья цепочки; терминал исполняет весь план одной транзакцией. Этап 6: restore и
|
|
4
|
+
* purge — над удалёнными объектами шага, rekey — новое значение ключа (функции базы).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* await db.Org(org).Staff().create({ name: 'Вася', phone: '+7' }).rows() // концы из пути: { Org }
|
|
7
|
+
* await db.Customer(c).booking({ notes: 'x' }).update({ notes: 'y' }).rows()
|
|
8
|
+
* await db.Wallet(w).update({ balance: inc(-300) }).first() // приращение в базе
|
|
9
|
+
* await db.Staff(s).booking().create({ … }).Customer.set(c).Item.set(svc).rows() // слоты концов
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Библиотека пишет обычными операторами SQL — теми же, что выполнил бы человек в консоли:
|
|
12
|
+
* insert, update … set data = merge(data, $patch), insert … on conflict do update, delete; всё
|
|
13
|
+
* проверяют триггеры базы. Продолжение после глагола идёт от его результата (fan-out по строкам),
|
|
14
|
+
* глагол сразу после глагола пишет в те же сущности. Всё, что проверяется без базы, проверяется
|
|
15
|
+
* до отправки первого оператора (precheckPlan): такая ошибка не ломает явную транзакцию.
|
|
16
|
+
* Если update или delete затронул меньше строк, чем шаг нашёл, план откатывается:
|
|
17
|
+
* строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
|
|
18
|
+
* разошёлся со { rev } — conflict.
|
|
16
19
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// END_MODULE_CONTRACT
|
|
28
|
-
//
|
|
29
|
-
// START_MODULE_MAP
|
|
30
|
-
// ValidationError - ошибка валидации с полями issues
|
|
31
|
-
// toRow/readRows/deepMerge - нормализация строки, чтение, deep-merge патча
|
|
32
|
-
// createOp/updateOp/anonymizeOp/delOp - операции записи (create/update/anonymize/delete)
|
|
33
|
-
// runPlan - исполнить план (сегменты op + читающий хвост) одной транзакцией
|
|
34
|
-
// executeBatch - исполнить очередь планов (fast-path multi-insert для чистых INSERT)
|
|
35
|
-
// BatchPlan/PlanMode - модель плана батча и режимы чтения
|
|
36
|
-
// END_MODULE_MAP
|
|
37
|
-
//
|
|
38
|
-
// START_CHANGE_SUMMARY
|
|
39
|
-
// LAST_CHANGE: [v1.1.0 - resolveAccount зовёт guardScoped: при enforceAccount без scope (db.as)
|
|
40
|
-
// запись запрещена с внятной подсказкой; ctx.account теперь приходит из scope, не из connect.
|
|
41
|
-
// Ранее: Documented existing module: reverse-engineered contract + markup]
|
|
42
|
-
// END_CHANGE_SUMMARY
|
|
43
|
-
import { randomUUID } from 'node:crypto';
|
|
44
|
-
import { uuidv5, uuidv7 } from './uuid.js';
|
|
45
|
-
import { buildRead, guardScoped, runQuery, insertSql, multiInsertSql, deleteSql, closureSql, purgeCallSql, isTransient, retryDelay, RETRIES, } from './sql.js';
|
|
46
|
-
import { aclDenied } from './acl.js';
|
|
47
|
-
// START_CONTRACT: ValidationError
|
|
48
|
-
// PURPOSE: Ошибка валидации класса, несущая список проблемных полей (issues).
|
|
49
|
-
// INPUTS: { cls: string; issues: {field, message?}[] }
|
|
50
|
-
// OUTPUTS: { Error - message с перечнем «поле — сообщение» }
|
|
51
|
-
// SIDE_EFFECTS: none
|
|
52
|
-
// LINKS: M-WRITE, V-M-WRITE
|
|
53
|
-
// END_CONTRACT: ValidationError
|
|
54
|
-
export class ValidationError extends Error {
|
|
55
|
-
issues;
|
|
56
|
-
constructor(cls, issues) {
|
|
57
|
-
super(`letopis: validation failed for "${cls}": ${issues.map((i) => `${i.field} — ${i.message ?? 'invalid'}`).join('; ')}`);
|
|
58
|
-
this.issues = issues;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
export function toRow(r) {
|
|
62
|
-
return {
|
|
63
|
-
id: r.id,
|
|
64
|
-
class: r.class,
|
|
65
|
-
data: r.data ?? {},
|
|
66
|
-
links: r.links ?? {},
|
|
67
|
-
tags: r.tags ?? [],
|
|
68
|
-
account: r.account,
|
|
69
|
-
owner: r.owner,
|
|
70
|
-
updated: r.updated instanceof Date ? r.updated.toISOString() : r.updated,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
/** Прочитать актуальные строки по цепочке. */
|
|
74
|
-
export async function readRows(ctx, steps, mods = {}) {
|
|
75
|
-
const q = buildRead(ctx, steps, mods, 'rows');
|
|
76
|
-
const res = await runQuery(ctx, q.text, q.params, 'rows', steps.map((s) => s.cls.id));
|
|
77
|
-
return res.map((r) => toRow(r.row));
|
|
78
|
-
}
|
|
79
|
-
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date);
|
|
20
|
+
import { LetopisError, ValidationError } from './errors.js';
|
|
21
|
+
import { isOp } from './ops.js';
|
|
22
|
+
import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
|
|
23
|
+
import { idOf } from './uuid.js';
|
|
24
|
+
import { applyDefaults } from './validate.js';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Приращение: маркер, которого не получить из JSON (§2.5)
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
|
|
29
|
+
export const INC = Symbol.for('letopis.inc');
|
|
80
30
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
31
|
+
* Приращение в update: `update({ balance: inc(-300) })` — величина прибавляется в базе, внутри
|
|
32
|
+
* оператора, над свежей строкой (функция inc). Поля нет — ошибка; `{ start }` — начать с него.
|
|
83
33
|
*/
|
|
84
|
-
export function
|
|
85
|
-
|
|
86
|
-
for (const [k, v] of Object.entries(patch)) {
|
|
87
|
-
if (v === undefined)
|
|
88
|
-
continue;
|
|
89
|
-
out[k] = isPlainObject(v) && isPlainObject(out[k])
|
|
90
|
-
? deepMerge(out[k], v)
|
|
91
|
-
: v;
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
34
|
+
export function inc(by, opts = {}) {
|
|
35
|
+
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
94
36
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
37
|
+
export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
|
|
38
|
+
const fail = (code, msg, detail) => {
|
|
39
|
+
throw new LetopisError(code, msg, detail);
|
|
40
|
+
};
|
|
41
|
+
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
42
|
+
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
|
|
43
|
+
/** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
|
|
44
|
+
export function splitPatch(patch) {
|
|
45
|
+
const incs = [];
|
|
46
|
+
const noMarkers = (v, path) => {
|
|
47
|
+
if (isInc(v))
|
|
48
|
+
fail('invalid_data', `inc() внутри массива (${path.join('.')}) не поддерживается`);
|
|
49
|
+
if (isOp(v))
|
|
50
|
+
fail('invalid_data', `оператор фильтра в данных (${path.join('.')}) — данные записываются значениями`);
|
|
51
|
+
if (Array.isArray(v))
|
|
52
|
+
v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
|
|
53
|
+
else if (isPlain(v))
|
|
54
|
+
for (const [k, x] of Object.entries(v))
|
|
55
|
+
noMarkers(x, [...path, k]);
|
|
56
|
+
};
|
|
57
|
+
const walk = (obj, path) => {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
60
|
+
if (v === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
const p = [...path, k];
|
|
63
|
+
if (isInc(v)) {
|
|
64
|
+
if (!Number.isFinite(v.by))
|
|
65
|
+
fail('invalid_data', `inc(${v.by}) по пути ${p.join('.')}: величина — конечное число`);
|
|
66
|
+
if (v.start !== undefined && !Number.isFinite(v.start))
|
|
67
|
+
fail('invalid_data', `inc: start по пути ${p.join('.')} — конечное число`);
|
|
68
|
+
incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
|
|
124
69
|
}
|
|
125
|
-
if (
|
|
126
|
-
|
|
70
|
+
else if (isOp(v)) {
|
|
71
|
+
fail('invalid_data', `оператор фильтра в патче (${p.join('.')}) — update записывает значения`);
|
|
127
72
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
let polymorphic = 0;
|
|
132
|
-
for (const end of cls.links) {
|
|
133
|
-
const name = end.classes[0];
|
|
134
|
-
if (name === 'Entity')
|
|
135
|
-
polymorphic++;
|
|
136
|
-
else if (present.has(name))
|
|
137
|
-
present.delete(name);
|
|
138
|
-
else
|
|
139
|
-
throw new Error(`letopis: link "${cls.id}" requires end "${name}"`);
|
|
73
|
+
else if (Array.isArray(v)) {
|
|
74
|
+
noMarkers(v, p);
|
|
75
|
+
out[k] = v;
|
|
140
76
|
}
|
|
141
|
-
if (
|
|
142
|
-
|
|
77
|
+
else if (isPlain(v)) {
|
|
78
|
+
const before = incs.length;
|
|
79
|
+
const sub = walk(v, p);
|
|
80
|
+
// объект, опустевший после изъятия маркеров, не подставляется вместо значения на пути
|
|
81
|
+
if (Object.keys(sub).length || incs.length === before)
|
|
82
|
+
out[k] = sub;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
out[k] = v;
|
|
143
86
|
}
|
|
144
87
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
return { rest: walk(patch, []), incs };
|
|
148
91
|
}
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Проверки до отправки (precheckPlan)
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
/** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript (full — объект целиком). */
|
|
96
|
+
function checkFullData(cls, data, verb, full = true) {
|
|
97
|
+
if (!isPlain(data))
|
|
98
|
+
fail('invalid_data', `${verb}(): данные — объект`);
|
|
99
|
+
const scan = (v, path) => {
|
|
100
|
+
if (isInc(v))
|
|
101
|
+
fail('invalid_data', `inc() работает только в update(): ${verb}() по пути ${path.join('.')}`);
|
|
102
|
+
if (isOp(v))
|
|
103
|
+
fail('invalid_data', `оператор фильтра в данных ${verb}() (${path.join('.')}) — данные записываются значениями`);
|
|
104
|
+
if (Array.isArray(v))
|
|
105
|
+
v.forEach((x, i) => scan(x, [...path, String(i)]));
|
|
106
|
+
else if (isPlain(v))
|
|
107
|
+
for (const [k, x] of Object.entries(v))
|
|
108
|
+
scan(x, [...path, k]);
|
|
109
|
+
};
|
|
110
|
+
scan(data, []);
|
|
111
|
+
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
112
|
+
if (!full || cls.name === 'Class' || cls.abstract)
|
|
113
|
+
return;
|
|
114
|
+
const issues = cls.validator()(applyDefaults(cls.schema, data));
|
|
115
|
+
if (issues.length)
|
|
116
|
+
throw new ValidationError(cls.name, issues);
|
|
117
|
+
}
|
|
118
|
+
/** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
|
|
119
|
+
export function precheckPlan(reg, steps) {
|
|
120
|
+
let prevOp;
|
|
121
|
+
for (let i = 0; i < steps.length; i++) {
|
|
122
|
+
const s = steps[i];
|
|
123
|
+
const op = s.op;
|
|
124
|
+
if (!op) {
|
|
125
|
+
prevOp = undefined;
|
|
126
|
+
continue;
|
|
168
127
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
128
|
+
const verb = op.kind;
|
|
129
|
+
const m = s.opMods ?? {};
|
|
130
|
+
if (m.asOf !== undefined)
|
|
131
|
+
fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
|
|
132
|
+
// restore и purge сами ищут удалённые объекты шага
|
|
133
|
+
if (m.withDeleted && verb !== 'restore' && verb !== 'purge')
|
|
134
|
+
fail('invalid_query', `${verb}(): withDeleted описывает удалённые — записывать в них нельзя`);
|
|
135
|
+
switch (op.kind) {
|
|
136
|
+
case 'create':
|
|
137
|
+
case 'upsert':
|
|
138
|
+
if (s.self)
|
|
139
|
+
fail('invalid_query', `${verb}() сразу после ${prevOp?.kind}() — между глаголами нужен шаг`);
|
|
140
|
+
if (s.pivotKey !== undefined)
|
|
141
|
+
fail('invalid_query', `${verb}() на возврате к узлу — он ведёт к существующей сущности, используйте update()`);
|
|
142
|
+
if (s.filter !== undefined && typeof s.filter !== 'string') {
|
|
143
|
+
fail('invalid_query', `${verb}() не ищет: ${s.name}(id).${verb}(…) задаёт id, поиск — это update()`);
|
|
177
144
|
}
|
|
145
|
+
if (op.kind === 'upsert' && !s.cls.key.length)
|
|
146
|
+
fail('no_key', `upsert(): у класса ${s.cls.name} нет ключа — сравнивать не с чем, используйте create()`);
|
|
147
|
+
checkFullData(s.cls, op.data, verb);
|
|
148
|
+
if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
|
|
149
|
+
fail('invalid_query', `.tags() перед ${verb}() — строка или список`);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case 'update':
|
|
153
|
+
if (!isPlain(op.data))
|
|
154
|
+
fail('invalid_data', 'update(): патч — объект');
|
|
155
|
+
splitPatch(op.data);
|
|
156
|
+
if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
|
|
157
|
+
fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
|
|
158
|
+
break;
|
|
159
|
+
case 'anonymize': {
|
|
160
|
+
if (!Array.isArray(op.fields) || !op.fields.length)
|
|
161
|
+
fail('invalid_query', 'anonymize(поля): нужен список полей');
|
|
162
|
+
for (const f of op.fields) {
|
|
163
|
+
const t = reg.leafType(s.cls.name, [f]);
|
|
164
|
+
if (t !== 'text')
|
|
165
|
+
fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
178
168
|
}
|
|
179
|
-
|
|
169
|
+
case 'reclass':
|
|
170
|
+
reg.resolve(op.cls);
|
|
171
|
+
if (op.data !== undefined)
|
|
172
|
+
checkFullData(reg.resolve(op.cls), op.data, 'reclass');
|
|
173
|
+
break;
|
|
174
|
+
case 'delete':
|
|
175
|
+
case 'restore':
|
|
176
|
+
case 'purge':
|
|
177
|
+
break;
|
|
178
|
+
case 'rekey':
|
|
179
|
+
if (!s.cls.key.length)
|
|
180
|
+
fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
|
|
181
|
+
checkFullData(s.cls, op.data, 'rekey', false);
|
|
182
|
+
break;
|
|
180
183
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* enforceAcl: право операции на класс цели. Предикат победившего правила:
|
|
186
|
-
* - фильтрует цели UPDATE/DELETE (step.aclFilter → WHERE при поиске);
|
|
187
|
-
* - колоночные ключи owner/account пришпиливают значения INSERT
|
|
188
|
-
* (явный чужой модификатор на цепочке → ошибка).
|
|
189
|
-
* Мутирует step (объект создаётся цепочкой на вызов — локально безопасно).
|
|
190
|
-
*/
|
|
191
|
-
// START_CONTRACT: aclWrite
|
|
192
|
-
// PURPOSE: enforceAcl-проверка права записи/удаления на класс цели; предикат правила пришпиливает owner/account и фильтрует цели.
|
|
193
|
-
// INPUTS: { ctx: Ctx; step: Step; op: 'WRITE'|'DELETE' }
|
|
194
|
-
// OUTPUTS: { void - мутирует step (aclFilter/ownerFilter/accountFilter) }
|
|
195
|
-
// SIDE_EFFECTS: мутирует step
|
|
196
|
-
// ERRORS: acl denies WRITE/DELETE; acl pins writes to owner/account
|
|
197
|
-
// LINKS: M-WRITE, V-M-WRITE, M-ACL
|
|
198
|
-
// END_CONTRACT: aclWrite
|
|
199
|
-
function aclWrite(ctx, step, op) {
|
|
200
|
-
if (!ctx.aclDecide)
|
|
201
|
-
return;
|
|
202
|
-
const d = ctx.aclDecide(step.cls, op);
|
|
203
|
-
if (!d.allow)
|
|
204
|
-
throw aclDenied(op, step.cls.id, d);
|
|
205
|
-
if (!d.filter)
|
|
206
|
-
return;
|
|
207
|
-
step.aclFilter = d.filter;
|
|
208
|
-
for (const col of ['owner', 'account']) {
|
|
209
|
-
const v = d.filter[col];
|
|
210
|
-
if (typeof v !== 'string')
|
|
211
|
-
continue;
|
|
212
|
-
const key = col === 'owner' ? 'ownerFilter' : 'accountFilter';
|
|
213
|
-
if (step[key] && step[key] !== v) {
|
|
214
|
-
throw new Error(`letopis: acl pins ${step.cls.id} writes to ${col} ${v}`);
|
|
184
|
+
for (const sl of s.slots ?? []) {
|
|
185
|
+
if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
|
|
186
|
+
fail('invalid_query', `слот .${sl.role} после ${verb}() — слоты у create(), update() и upsert()`);
|
|
215
187
|
}
|
|
216
|
-
|
|
188
|
+
prevOp = op;
|
|
217
189
|
}
|
|
218
190
|
}
|
|
219
|
-
const
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
const acc = step.accountFilter ?? ctx.account ?? ctx.systemAccount;
|
|
227
|
-
if (!acc) {
|
|
228
|
-
throw new Error('letopis: Entity.account is NOT NULL — set .account(…) / db.as(account) or seed a System account (lib/sql/seed.auth.sql)');
|
|
229
|
-
}
|
|
230
|
-
return acc;
|
|
191
|
+
const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
192
|
+
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
193
|
+
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
194
|
+
+ `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
|
|
195
|
+
+ `'op', ${a}.op, 'reason', ${a}.reason, 'moved', ${a}.moved)`;
|
|
196
|
+
async function query(w, text, params) {
|
|
197
|
+
return (await w.q.unsafe(text, params, { prepare: true }));
|
|
231
198
|
}
|
|
232
|
-
/**
|
|
233
|
-
function
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
return undefined;
|
|
237
|
-
if (typeof t === 'string')
|
|
238
|
-
return [t];
|
|
239
|
-
if (Array.isArray(t))
|
|
240
|
-
return t;
|
|
241
|
-
throw new Error('letopis: .tags() before set() accepts string | string[]');
|
|
199
|
+
/** Чтение в транзакции плана — тот же построитель, что у цепочек. */
|
|
200
|
+
export async function readIn(w, steps, mods, mode, agg) {
|
|
201
|
+
const q = buildRead(w.reg, w.schema, steps, mods, mode, agg);
|
|
202
|
+
return { q, res: await query(w, q.text, q.params) };
|
|
242
203
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
204
|
+
const readRows = async (w, steps, mods) => (await readIn(w, steps, mods, 'rows')).res.map((r) => toRow(r.row));
|
|
205
|
+
const readIds = async (w, steps, mods) => (await readIn(w, steps, mods, 'ids')).res.map((r) => r.id);
|
|
206
|
+
/** Шаг без звена записи — для чтения целей. */
|
|
207
|
+
const bare = (s) => {
|
|
208
|
+
const { op: _o, opMods: _m, slots: _s, self: _f, ...rest } = s;
|
|
209
|
+
return rest;
|
|
210
|
+
};
|
|
211
|
+
/** Роли класса owner, которые принимают класс cls. */
|
|
212
|
+
function rolesAccepting(reg, owner, cls) {
|
|
213
|
+
return Object.entries(owner.ends).filter(([, e]) => accepts(reg, asList(e.class), cls)).map(([r]) => r);
|
|
246
214
|
}
|
|
215
|
+
/** Роль слота: имя роли записываемого класса или класс, который принимает ровно одна роль. */
|
|
216
|
+
export function slotRole(reg, owner, name) {
|
|
217
|
+
if (owner.ends[name])
|
|
218
|
+
return name;
|
|
219
|
+
const c = reg.find(name);
|
|
220
|
+
if (!c)
|
|
221
|
+
fail('invalid_class', `класс или роль ${name} не найдены`);
|
|
222
|
+
const roles = rolesAccepting(reg, owner, c.name);
|
|
223
|
+
if (roles.length === 1)
|
|
224
|
+
return roles[0];
|
|
225
|
+
if (!roles.length)
|
|
226
|
+
fail('invalid_query', `${name} — не конец класса ${owner.name} (концы: ${Object.keys(owner.ends).join(', ') || '—'})`);
|
|
227
|
+
return fail('invalid_query', `слот ${name} у класса ${owner.name} неоднозначен: роли ${roles.join(', ')} — назовите роль`);
|
|
228
|
+
}
|
|
229
|
+
/** Неоднозначная роль конца из пути. */
|
|
230
|
+
const ambiguous = (step, target, roles) => fail('invalid_query', `конец класса ${target.name} для шага ${step.name} неоднозначен: роли ${roles.join(', ')} — назовите шаг ролью или задайте слот`);
|
|
247
231
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* от начала до него: контекст честно учитывается).
|
|
232
|
+
* Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
|
|
233
|
+
* класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
|
|
251
234
|
*/
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
235
|
+
function pickRole(step, target, roles, taken, cls = step.cls.name) {
|
|
236
|
+
const free = roles.filter((r) => !taken.has(r));
|
|
237
|
+
if (free.length <= 1)
|
|
238
|
+
return free;
|
|
239
|
+
const named = free.filter((r) => r === cls || r === step.name);
|
|
240
|
+
return named.length === 1 ? named : ambiguous(step, target, free);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Концы создаваемой строки из пути (resolvePathEnds 0.21, этап 4, п. 4): каждый шаг до глагола,
|
|
244
|
+
* на класс которого ссылается конец создаваемого класса, даёт ровно одну сущность. Шаг-роль ведёт
|
|
245
|
+
* своей ролью; шаг, на который создаваемая строка не ссылается (переход вперёд), пропускается;
|
|
246
|
+
* шаг по базовому классу выбирает роль по классу найденной строки.
|
|
247
|
+
*/
|
|
248
|
+
async function pathEnds(w, steps, taken) {
|
|
249
|
+
const target = steps[steps.length - 1];
|
|
250
|
+
const tcls = target.cls;
|
|
262
251
|
const links = {};
|
|
263
252
|
for (let k = 0; k < steps.length - 1; k++) {
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
253
|
+
const s = steps[k];
|
|
254
|
+
if (s.pivotKey !== undefined || s.op)
|
|
266
255
|
continue;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (typeof step.filter === 'string') {
|
|
271
|
-
id = step.filter;
|
|
256
|
+
let roles;
|
|
257
|
+
if (s.role && tcls.ends[s.role]) {
|
|
258
|
+
roles = [s.role];
|
|
272
259
|
}
|
|
273
260
|
else {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
261
|
+
if (k === steps.length - 2 && !target.role) {
|
|
262
|
+
const hop = resolveHop(w.reg, s, target);
|
|
263
|
+
if (hop.dir === 'forward')
|
|
264
|
+
continue; // предыдущий шаг ссылается на создаваемую — не наоборот
|
|
265
|
+
}
|
|
266
|
+
else if (k === steps.length - 2) {
|
|
267
|
+
continue; // создаётся цель роли предыдущего шага: ссылку ставит не она
|
|
268
|
+
}
|
|
269
|
+
roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, s.cls.name), taken);
|
|
270
|
+
if (!roles.length) {
|
|
271
|
+
const fam = stepClasses(w.reg, s);
|
|
272
|
+
if (!fam.some((c) => rolesAccepting(w.reg, tcls, c).length))
|
|
273
|
+
continue;
|
|
274
|
+
roles = null; // роль решит класс найденной строки
|
|
277
275
|
}
|
|
278
|
-
id = rows[0].id;
|
|
279
276
|
}
|
|
280
|
-
links[step.cls.id] = id;
|
|
281
|
-
}
|
|
282
|
-
return links;
|
|
283
|
-
}
|
|
284
|
-
/** Резолв слот-значений: строки как есть; вложенные планы — в той же транзакции, ровно одна сущность. */
|
|
285
|
-
// START_CONTRACT: resolveSlots
|
|
286
|
-
// PURPOSE: Резолв слот-значений (.Класс.set/.unset): строки как есть, вложенные планы — та же транзакция и ровно одна сущность; союз-конец снимается целиком.
|
|
287
|
-
// INPUTS: { ctx: Ctx; target: ClassDef; extra: Step['extraLinks'] }
|
|
288
|
-
// OUTPUTS: { Promise<{ vals: Record<string,string>; drops: Set<string> }> }
|
|
289
|
-
// SIDE_EFFECTS: может исполнять вложенные планы/чтения
|
|
290
|
-
// ERRORS: slot value plan must resolve to exactly one entity; resolved to class "<x>"
|
|
291
|
-
// LINKS: M-WRITE, V-M-WRITE
|
|
292
|
-
// END_CONTRACT: resolveSlots
|
|
293
|
-
async function resolveSlots(ctx, target, extra) {
|
|
294
|
-
const vals = {};
|
|
295
|
-
const drops = new Set();
|
|
296
|
-
for (const [clsId, v] of Object.entries(extra ?? {})) {
|
|
297
|
-
// союз-замещение: слот занимает КОНЕЦ целиком — соседние классы союза снимаются
|
|
298
|
-
const end = endFor(target, ctx.registry.resolve(clsId));
|
|
299
|
-
for (const c of end?.classes ?? [clsId])
|
|
300
|
-
drops.add(c);
|
|
301
|
-
if (v === null)
|
|
302
|
-
continue; // .delete() — только снятие
|
|
303
277
|
let id;
|
|
304
|
-
if (typeof
|
|
305
|
-
id =
|
|
278
|
+
if (typeof s.filter === 'string' && roles) {
|
|
279
|
+
id = s.filter;
|
|
306
280
|
}
|
|
307
281
|
else {
|
|
308
|
-
const rows =
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (rows.length !== 1) {
|
|
312
|
-
throw new Error(`letopis: slot "${clsId}" value plan must resolve to exactly one entity (got ${rows.length})`);
|
|
313
|
-
}
|
|
314
|
-
if (rows[0].class !== clsId) {
|
|
315
|
-
throw new Error(`letopis: slot "${clsId}" value plan resolved to class "${rows[0].class}"`);
|
|
316
|
-
}
|
|
282
|
+
const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
|
|
283
|
+
if (rows.length !== 1)
|
|
284
|
+
fail('invalid_query', `шаг ${s.name} должен дать ровно одну сущность для конца ${tcls.name} (найдено ${rows.length})`);
|
|
317
285
|
id = rows[0].id;
|
|
286
|
+
if (!roles) {
|
|
287
|
+
roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
|
|
288
|
+
if (!roles.length)
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const role = roles[0];
|
|
293
|
+
if (tcls.ends[role]?.many) {
|
|
294
|
+
const cur = asList(links[role] ?? []);
|
|
295
|
+
if (!cur.includes(id))
|
|
296
|
+
links[role] = [...cur, id];
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
links[role] = id;
|
|
318
300
|
}
|
|
319
|
-
vals[clsId] = id;
|
|
320
301
|
}
|
|
321
|
-
return
|
|
302
|
+
return links;
|
|
322
303
|
}
|
|
323
|
-
/**
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (seg)
|
|
337
|
-
return seg.slice('default:'.length);
|
|
304
|
+
/** Значения слотов: строки как есть, цепочки — в той же транзакции. */
|
|
305
|
+
async function slotIds(w, v) {
|
|
306
|
+
if (v === null)
|
|
307
|
+
return [];
|
|
308
|
+
if (typeof v === 'string')
|
|
309
|
+
return [v];
|
|
310
|
+
if (Array.isArray(v))
|
|
311
|
+
return v;
|
|
312
|
+
if ('plan' in v) {
|
|
313
|
+
const rows = v.plan.some((s) => s.op)
|
|
314
|
+
? (await execPlan(w, v.plan, v.mods, 'rows')).rows
|
|
315
|
+
: await readRows(w, v.plan, v.mods);
|
|
316
|
+
return rows.map((r) => r.id);
|
|
338
317
|
}
|
|
339
|
-
return
|
|
318
|
+
return [v.id];
|
|
340
319
|
}
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// INPUTS: { ctx: Ctx; cls: ClassDef; links: object; data: object }
|
|
350
|
-
// OUTPUTS: { string - uuid v5 }
|
|
351
|
-
// SIDE_EFFECTS: none
|
|
352
|
-
// ERRORS: needs end "<...>"; needs scalar data field "<f>"
|
|
353
|
-
// LINKS: M-WRITE, V-M-WRITE, M-UUID
|
|
354
|
-
// END_CONTRACT: v5Id
|
|
355
|
-
function v5Id(ctx, cls, links, data) {
|
|
356
|
-
const parts = [];
|
|
357
|
-
for (const f of cls.idGen.from) {
|
|
358
|
-
const end = cls.links.find((e) => e.classes.includes(f) || e.classes.join('|') === f);
|
|
359
|
-
if (end) {
|
|
360
|
-
const hit = end.classes.find((c) => c in links);
|
|
361
|
-
if (!hit) {
|
|
362
|
-
throw new Error(`letopis: id (uuid v5) of "${cls.id}" needs end "${end.classes.join('|')}" — set it in the path or with a slot`);
|
|
363
|
-
}
|
|
364
|
-
parts.push(links[hit]);
|
|
320
|
+
/** Слоты → операции links_patch; для новой строки — применяются к концам из пути. */
|
|
321
|
+
async function resolveSlots(w, cls, slots) {
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const sl of slots ?? []) {
|
|
324
|
+
const end = cls.ends[sl.role];
|
|
325
|
+
const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
|
|
326
|
+
if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
|
|
327
|
+
fail('invalid_query', `слот .${sl.role}.set(): конец одиночный, а значение дало ${ids.length} сущностей`);
|
|
365
328
|
}
|
|
329
|
+
out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
function applySlotsLocal(links, ops) {
|
|
334
|
+
const out = { ...links };
|
|
335
|
+
for (const o of ops) {
|
|
336
|
+
if (o.op === 'unset' || (o.op === 'set' && o.value === null))
|
|
337
|
+
delete out[o.role];
|
|
338
|
+
else if (o.op === 'set')
|
|
339
|
+
out[o.role] = o.value;
|
|
366
340
|
else {
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
parts.push(String(v));
|
|
341
|
+
const cur = asList(out[o.role] ?? []).map((x) => x.toLowerCase());
|
|
342
|
+
const vals = asList(o.value ?? []).map((x) => x.toLowerCase());
|
|
343
|
+
out[o.role] = o.op === 'add' ? [...cur, ...vals.filter((x) => !cur.includes(x))] : cur.filter((x) => !vals.includes(x));
|
|
372
344
|
}
|
|
373
345
|
}
|
|
374
|
-
return
|
|
346
|
+
return out;
|
|
375
347
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
348
|
+
const tagsValue = (s) => s.tagsFilter === undefined ? null : typeof s.tagsFilter === 'string' ? [s.tagsFilter] : s.tagsFilter;
|
|
349
|
+
/** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
|
|
350
|
+
function existsError(w, cls, data, links, given, cause) {
|
|
351
|
+
let id = given;
|
|
352
|
+
if (!id && cls.key.length && w.tenant) {
|
|
353
|
+
try {
|
|
354
|
+
id = idOf({ name: cls.name, key: cls.key, ends: Object.keys(cls.ends) }, { ...data, ...links }, { tenant: w.tenant });
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
id = undefined;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return Object.assign(new LetopisError('exists', `объект класса ${cls.name} уже существует${id ? ` (id ${id})` : ''}`, { id: id ?? null }), { cause });
|
|
361
|
+
}
|
|
362
|
+
const isPkConflict = (e) => {
|
|
363
|
+
const err = e;
|
|
364
|
+
return err.code === '23505' && err.constraint_name === 'entity_pkey';
|
|
365
|
+
};
|
|
366
|
+
async function createOp(w, steps, opStep, upsert) {
|
|
367
|
+
const op = opStep.op;
|
|
368
|
+
const cls = opStep.cls;
|
|
369
|
+
const S = quoteId(w.schema);
|
|
370
|
+
const slots = await resolveSlots(w, cls, opStep.slots);
|
|
371
|
+
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
372
|
+
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
373
|
+
const owner = opStep.ownerFilter ?? w.owner ?? null;
|
|
374
|
+
const conflict = upsert
|
|
375
|
+
? ` on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags`
|
|
376
|
+
: '';
|
|
377
|
+
const text = `insert into ${S}.entity (id, class, links, data, tags, owner) values ($1::uuid, $2::text, $3::jsonb, $4::jsonb, $5::text[], $6::uuid)`
|
|
378
|
+
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
379
|
+
try {
|
|
380
|
+
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
|
|
381
|
+
return res.map((r) => {
|
|
382
|
+
const row = toRow(r.row);
|
|
383
|
+
if (upsert)
|
|
384
|
+
row.$upsert = r.old_rev === null ? 'created' : r.old_rev === row.rev ? 'unchanged' : 'updated';
|
|
385
|
+
return row;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (!upsert && isPkConflict(e))
|
|
390
|
+
throw existsError(w, cls, op.data, links, given, e);
|
|
391
|
+
throw e;
|
|
392
392
|
}
|
|
393
|
-
return out;
|
|
394
393
|
}
|
|
395
394
|
/**
|
|
396
|
-
* .
|
|
397
|
-
*
|
|
398
|
-
* - id известен (Класс(id) / data.id / вычислен v5): есть → новая версия (deep-merge),
|
|
399
|
-
* нет → INSERT с этим id (идемпотентный create, REST-PUT семантика);
|
|
400
|
-
* - фильтр-объект / pivot — ошибка: create не ищет, это update().
|
|
395
|
+
* Меньше затронутых строк, чем нашёл шаг (§2.5): невидимые — target_not_found; видимые с другим
|
|
396
|
+
* номером версии в строгом режиме — conflict; остальные видимые — их не дала изменить RLS (acl_denied).
|
|
401
397
|
*/
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
// END_CONTRACT: createOp
|
|
410
|
-
export async function createOp(ctx, steps, mods, data) {
|
|
411
|
-
// START_BLOCK_CREATE_RESOLVE
|
|
412
|
-
const target = steps[steps.length - 1];
|
|
413
|
-
const cls = target.cls;
|
|
414
|
-
aclWrite(ctx, target, 'WRITE');
|
|
415
|
-
if (target.pivotKey !== undefined) {
|
|
416
|
-
throw new Error(`letopis: create() on a pivot step — pivot returns to an existing node, use update()`);
|
|
398
|
+
async function explainMissing(w, missing, rev) {
|
|
399
|
+
const S = quoteId(w.schema);
|
|
400
|
+
const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
|
|
401
|
+
if (!seen.length)
|
|
402
|
+
return fail('target_not_found', missing.length === 1 ? `цель ${missing[0]} не найдена` : `цели не найдены: ${missing.join(', ')}`, { ids: missing });
|
|
403
|
+
if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
|
|
404
|
+
return fail('conflict', `строку ${seen[0].id} успели изменить: версия ${seen[0].rev}, а не ${rev} — перечитайте и повторите`, { id: seen[0].id, rev: seen[0].rev, expected: rev });
|
|
417
405
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
406
|
+
return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
|
|
407
|
+
}
|
|
408
|
+
/** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
409
|
+
async function targetIds(w, steps, opStep, dataId) {
|
|
410
|
+
const path = steps.map(bare);
|
|
411
|
+
if (opStep.filter === undefined && dataId !== undefined)
|
|
412
|
+
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
413
|
+
return readIds(w, path, opStep.opMods ?? {});
|
|
414
|
+
}
|
|
415
|
+
async function updateOp(w, steps, opStep) {
|
|
416
|
+
const op = opStep.op;
|
|
417
|
+
const S = quoteId(w.schema);
|
|
418
|
+
// data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
|
|
419
|
+
const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
|
|
420
|
+
const { id: dataId, ...data } = op.data;
|
|
421
|
+
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
422
|
+
if (op.rev !== undefined) {
|
|
423
|
+
if (ids.length > 1)
|
|
424
|
+
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — строгий режим только для одной`);
|
|
425
|
+
if (!ids.length)
|
|
426
|
+
fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
|
|
421
427
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
428
|
+
if (!ids.length)
|
|
429
|
+
return [];
|
|
430
|
+
const { rest, incs } = splitPatch(useDataId ? data : op.data);
|
|
431
|
+
const params = [ids];
|
|
432
|
+
const ph = (v, cast) => `$${params.push(v)}::${cast}`;
|
|
433
|
+
let expr = `${S}.merge(e.data, ${ph(rest, 'jsonb')})`;
|
|
434
|
+
for (const i of incs)
|
|
435
|
+
expr = `${S}.inc(${expr}, ${ph(i.path, 'text[]')}, ${ph(i.by, 'numeric')}, ${ph(i.start ?? null, 'numeric')})`;
|
|
436
|
+
const sets = [`data = ${expr}`];
|
|
437
|
+
const slots = await resolveSlots(w, opStep.cls, opStep.slots);
|
|
438
|
+
if (slots.length)
|
|
439
|
+
sets.push(`links = ${S}.links_patch(e.links, ${ph(slots, 'jsonb')})`);
|
|
440
|
+
const tags = tagsValue(opStep);
|
|
441
|
+
if (tags)
|
|
442
|
+
sets.push(`tags = ${ph(tags, 'text[]')}`);
|
|
443
|
+
const revCond = op.rev !== undefined ? ` and e.rev = ${ph(op.rev, 'int')}` : '';
|
|
444
|
+
const res = await query(w, `update ${S}.entity e set ${sets.join(', ')} where e.id = any ($1::uuid[])${revCond} returning ${rowJson('new')} as row`, params);
|
|
445
|
+
if (res.length < ids.length) {
|
|
446
|
+
const got = new Set(res.map((r) => r.row.id));
|
|
447
|
+
await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
|
|
427
448
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
449
|
+
return res.map((r) => toRow(r.row));
|
|
450
|
+
}
|
|
451
|
+
async function deleteOp(w, steps, opStep) {
|
|
452
|
+
const op = opStep.op;
|
|
453
|
+
const S = quoteId(w.schema);
|
|
454
|
+
const ids = await targetIds(w, steps, opStep);
|
|
455
|
+
if (!ids.length)
|
|
456
|
+
return [];
|
|
457
|
+
if (!op.confirm) {
|
|
458
|
+
// превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
|
|
459
|
+
const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
|
|
460
|
+
+ `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
|
|
461
|
+
return res.map((r) => {
|
|
462
|
+
const row = toRow(r.row);
|
|
463
|
+
row.$action = r.action;
|
|
464
|
+
if (r.depth !== null)
|
|
465
|
+
row.$depth = r.depth;
|
|
466
|
+
return row;
|
|
467
|
+
});
|
|
440
468
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
linksFilter: undefined,
|
|
459
|
-
};
|
|
460
|
-
const taken = await readRows(bare, [probe], {});
|
|
461
|
-
if (taken.length)
|
|
462
|
-
return [];
|
|
463
|
-
}
|
|
469
|
+
// результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
|
|
470
|
+
const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
|
|
471
|
+
const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
|
|
472
|
+
if (gone.length < ids.length) {
|
|
473
|
+
const got = new Set(gone.map((r) => r.row.id));
|
|
474
|
+
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
475
|
+
}
|
|
476
|
+
const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
|
|
477
|
+
const out = tombs.map((r) => toRow(r.row));
|
|
478
|
+
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
|
|
479
|
+
const have = new Set(out.map((r) => r.id));
|
|
480
|
+
for (const g of gone) {
|
|
481
|
+
if (have.has(g.row.id))
|
|
482
|
+
continue;
|
|
483
|
+
const row = toRow(g.row);
|
|
484
|
+
row.$deleted = true;
|
|
485
|
+
out.push(row);
|
|
464
486
|
}
|
|
465
|
-
|
|
466
|
-
const account = resolveAccount(ctx, target);
|
|
467
|
-
const owner = target.ownerFilter ?? ctx.owner ?? account;
|
|
468
|
-
const full = validate(cls, id, data, writeLinks);
|
|
469
|
-
return [
|
|
470
|
-
await insertOne(ctx, {
|
|
471
|
-
id, cls, data: full, links: writeLinks,
|
|
472
|
-
tags: tagsValue(target) ?? [], account, owner, prevUpdated: null, deleted: null,
|
|
473
|
-
}),
|
|
474
|
-
];
|
|
475
|
-
// END_BLOCK_CREATE_RESOLVE
|
|
487
|
+
return out;
|
|
476
488
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
489
|
+
async function anonymizeOp(w, steps, opStep) {
|
|
490
|
+
const op = opStep.op;
|
|
491
|
+
const S = quoteId(w.schema);
|
|
492
|
+
const ids = await targetIds(w, steps, opStep);
|
|
493
|
+
if (!ids.length)
|
|
494
|
+
return [];
|
|
495
|
+
const res = await query(w, `update ${S}.entity e set data = e.data || coalesce((select jsonb_object_agg(f, '[erased]'::text) from unnest($2::text[]) f where e.data ? f), '{}'::jsonb), `
|
|
496
|
+
+ `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
|
|
497
|
+
+ `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
|
|
498
|
+
if (res.length < ids.length) {
|
|
499
|
+
const got = new Set(res.map((r) => r.row.id));
|
|
500
|
+
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
501
|
+
}
|
|
502
|
+
return res.map((r) => toRow(r.row));
|
|
503
|
+
}
|
|
504
|
+
async function reclassOp(w, steps, opStep) {
|
|
505
|
+
const op = opStep.op;
|
|
506
|
+
const S = quoteId(w.schema);
|
|
507
|
+
const ids = await targetIds(w, steps, opStep);
|
|
508
|
+
if (!ids.length)
|
|
509
|
+
return [];
|
|
510
|
+
const cls = w.reg.resolve(op.cls).name;
|
|
511
|
+
// у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
|
|
512
|
+
const moved = await query(w, `select ${S}.reclass(x, $2::text, $3::jsonb) as id from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids, cls, op.data ?? null]);
|
|
513
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved.map((m) => m.id)]);
|
|
514
|
+
return res.map((r) => toRow(r.row));
|
|
515
|
+
}
|
|
516
|
+
/** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
|
|
517
|
+
async function deletedTargets(w, steps, opStep, verb) {
|
|
518
|
+
const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
|
|
519
|
+
const dead = rows.filter((r) => r.$deleted);
|
|
520
|
+
if (!dead.length && rows.length)
|
|
521
|
+
fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
|
|
522
|
+
return dead;
|
|
523
|
+
}
|
|
524
|
+
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
525
|
+
async function restoreOp(w, steps, opStep) {
|
|
526
|
+
const S = quoteId(w.schema);
|
|
527
|
+
const dead = await deletedTargets(w, steps, opStep, 'restore');
|
|
528
|
+
if (!dead.length)
|
|
529
|
+
return [];
|
|
530
|
+
const ids = dead.map((r) => r.id);
|
|
531
|
+
await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
|
|
532
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [ids]);
|
|
533
|
+
return res.map((r) => toRow(r.row));
|
|
500
534
|
}
|
|
501
535
|
/**
|
|
502
|
-
*
|
|
503
|
-
*
|
|
536
|
+
* Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
|
|
537
|
+
* версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
|
|
504
538
|
*/
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
for (const f of fields) {
|
|
510
|
-
if (cls.fieldTypes.get(f)?.kind !== 'string') {
|
|
511
|
-
throw new Error(`letopis: anonymize() erases string fields only; "${f}" is ${cls.fieldTypes.get(f)?.kind ?? 'unknown'}`);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
const found = await readRows(ctx, steps, mods); // цели — честный путь
|
|
539
|
+
async function purgeOp(w, steps, opStep) {
|
|
540
|
+
const op = opStep.op;
|
|
541
|
+
const S = quoteId(w.schema);
|
|
542
|
+
const dead = await deletedTargets(w, steps, opStep, 'purge');
|
|
515
543
|
const out = [];
|
|
516
|
-
for (const row of
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
const full = validate(cls, row.id, mergedData, row.links);
|
|
520
|
-
out.push(await insertOne(ctx, {
|
|
521
|
-
id: row.id, cls, data: full, links: row.links,
|
|
522
|
-
tags: [...new Set([...row.tags, 'anonymized'])],
|
|
523
|
-
account: row.account, owner: row.owner, prevUpdated: row.updated, deleted: null,
|
|
524
|
-
}));
|
|
544
|
+
for (const row of dead) {
|
|
545
|
+
const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
|
|
546
|
+
out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
|
|
525
547
|
}
|
|
526
548
|
return out;
|
|
527
549
|
}
|
|
528
|
-
/**
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
550
|
+
/** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
|
|
551
|
+
async function rekeyOp(w, steps, opStep) {
|
|
552
|
+
const op = opStep.op;
|
|
553
|
+
const S = quoteId(w.schema);
|
|
554
|
+
const ids = await targetIds(w, steps, opStep);
|
|
555
|
+
if (!ids.length)
|
|
556
|
+
return [];
|
|
557
|
+
// поля — в данные, роли концов — в концы
|
|
558
|
+
const data = {};
|
|
559
|
+
const links = {};
|
|
560
|
+
for (const [k, v] of Object.entries(op.data)) {
|
|
561
|
+
if (opStep.cls.ends[k])
|
|
562
|
+
links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
|
|
563
|
+
else
|
|
564
|
+
data[k] = v;
|
|
565
|
+
}
|
|
566
|
+
const moved = [];
|
|
567
|
+
for (const id of ids) {
|
|
535
568
|
try {
|
|
536
|
-
|
|
569
|
+
const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
|
|
570
|
+
moved.push(nid);
|
|
537
571
|
}
|
|
538
572
|
catch (e) {
|
|
539
|
-
if (
|
|
540
|
-
|
|
541
|
-
continue;
|
|
542
|
-
}
|
|
573
|
+
if (isPkConflict(e))
|
|
574
|
+
throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
|
|
543
575
|
throw e;
|
|
544
576
|
}
|
|
545
577
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* .delete({confirm}): цели = фильтр последнего шага + контекст-связи.
|
|
550
|
-
* confirm: true — серверное удаление (триггер entity_delete: tombstone + рекурсивный
|
|
551
|
-
* каскад + advisory-lock); возвращает ВСЁ удалённое (цели + каскад) с $deleted: true.
|
|
552
|
-
* Без confirm — ПРЕВЬЮ: то же замыкание (цели + каскад), но БД не трогается.
|
|
553
|
-
*/
|
|
554
|
-
// START_CONTRACT: delOp
|
|
555
|
-
// PURPOSE: .delete({confirm}) — серверное удаление (tombstone + рекурсивный каскад) при confirm, иначе превью замыкания; DELETE-право на каждый класс каскада.
|
|
556
|
-
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; confirm: boolean }
|
|
557
|
-
// OUTPUTS: { Promise<Row[]> - удалённые (цели+каскад) с $deleted, либо превью }
|
|
558
|
-
// SIDE_EFFECTS: DELETE в Entity (триггер entity_delete) при confirm
|
|
559
|
-
// ERRORS: acl denies DELETE (в каскаде)
|
|
560
|
-
// LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL
|
|
561
|
-
// END_CONTRACT: delOp
|
|
562
|
-
export async function delOp(ctx, steps, mods, confirm) {
|
|
563
|
-
const target = steps[steps.length - 1];
|
|
564
|
-
aclWrite(ctx, target, 'DELETE');
|
|
565
|
-
const targets = await readRows(ctx, steps, mods); // цели — честный путь
|
|
566
|
-
if (!targets.length)
|
|
567
|
-
return [];
|
|
568
|
-
const params = [ctx.partition, target.cls.id, ...targets.map((r) => r.id)];
|
|
569
|
-
// START_BLOCK_DELETE_CASCADE
|
|
570
|
-
return inTransaction(ctx, async (c) => {
|
|
571
|
-
const closure = await runQuery(c, closureSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
572
|
-
if (!confirm)
|
|
573
|
-
return closure.map((r) => toRow(r)); // превью: кандидаты живы, БД не тронута
|
|
574
|
-
// каскад дотянется до зависимых ЛЮБЫХ классов — на каждый нужно DELETE-право
|
|
575
|
-
if (c.aclDecide) {
|
|
576
|
-
for (const cn of new Set(closure.map((r) => r.class))) {
|
|
577
|
-
const d = c.aclDecide(c.registry.resolve(cn), 'DELETE');
|
|
578
|
-
if (!d.allow)
|
|
579
|
-
throw aclDenied('DELETE', `${cn} (в каскаде от ${target.cls.id})`, d);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
await runQuery(c, deleteSql(c.pgSchema, targets.length), params, 'delete', [target.cls.id]);
|
|
583
|
-
return closure.map((r) => ({ ...toRow(r), $deleted: true }));
|
|
584
|
-
});
|
|
585
|
-
// END_BLOCK_DELETE_CASCADE
|
|
578
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
|
|
579
|
+
return res.map((r) => toRow(r.row));
|
|
586
580
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
// SIDE_EFFECTS: физический DELETE в Entity через purge() (SET LOCAL letopis.purge отключает триггер)
|
|
600
|
-
// ERRORS: acl denies DELETE (класс цели)
|
|
601
|
-
// LINKS: M-WRITE, V-M-WRITE, M-ACL, M-DDL, M-SQL
|
|
602
|
-
// END_CONTRACT: purgeOp
|
|
603
|
-
export async function purgeOp(ctx, steps, mods, confirm) {
|
|
604
|
-
const target = steps[steps.length - 1];
|
|
605
|
-
aclWrite(ctx, target, 'DELETE');
|
|
606
|
-
// цели ищем ВКЛЮЧАЯ tombstone — .purge() работает по уже логически удалённым (двухфазность в purge())
|
|
607
|
-
const targets = await readRows(ctx, steps, { ...mods, withDeleted: true });
|
|
608
|
-
if (!targets.length)
|
|
609
|
-
return [];
|
|
610
|
-
return inTransaction(ctx, async (c) => {
|
|
611
|
-
const out = [];
|
|
612
|
-
// вся логика (двухфазность, замыкание, отключение триггера) — в серверной purge(); dry = !confirm
|
|
613
|
-
for (const t of targets) {
|
|
614
|
-
const rows = await runQuery(c, purgeCallSql(c.pgSchema), [c.partition, target.cls.id, t.id, !confirm], 'delete', [target.cls.id]);
|
|
615
|
-
for (const r of rows)
|
|
616
|
-
out.push(confirm ? { ...toRow(r), $deleted: true } : toRow(r));
|
|
617
|
-
}
|
|
618
|
-
return out;
|
|
619
|
-
});
|
|
581
|
+
function opCall(w, steps, opStep) {
|
|
582
|
+
switch (opStep.op.kind) {
|
|
583
|
+
case 'create': return createOp(w, steps, opStep, false);
|
|
584
|
+
case 'upsert': return createOp(w, steps, opStep, true);
|
|
585
|
+
case 'update': return updateOp(w, steps, opStep);
|
|
586
|
+
case 'delete': return deleteOp(w, steps, opStep);
|
|
587
|
+
case 'anonymize': return anonymizeOp(w, steps, opStep);
|
|
588
|
+
case 'reclass': return reclassOp(w, steps, opStep);
|
|
589
|
+
case 'restore': return restoreOp(w, steps, opStep);
|
|
590
|
+
case 'purge': return purgeOp(w, steps, opStep);
|
|
591
|
+
case 'rekey': return rekeyOp(w, steps, opStep);
|
|
592
|
+
}
|
|
620
593
|
}
|
|
621
|
-
/** Разрезать план: сегменты (…шаги +
|
|
622
|
-
function splitPlan(steps) {
|
|
594
|
+
/** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
|
|
595
|
+
export function splitPlan(steps) {
|
|
623
596
|
const segments = [];
|
|
624
597
|
let buf = [];
|
|
625
598
|
for (const s of steps) {
|
|
626
599
|
buf.push(s);
|
|
627
600
|
if (s.op) {
|
|
628
|
-
segments.push({ steps: buf
|
|
601
|
+
segments.push({ steps: buf });
|
|
629
602
|
buf = [];
|
|
630
603
|
}
|
|
631
604
|
}
|
|
632
605
|
return { segments, tail: buf };
|
|
633
606
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return updateOp(c, steps, op.mods, op.data ?? {});
|
|
640
|
-
case 'anonymize':
|
|
641
|
-
return anonymizeOp(c, steps, op.mods, op.fields ?? []);
|
|
642
|
-
case 'delete':
|
|
643
|
-
return delOp(c, steps, op.mods, op.confirm === true);
|
|
644
|
-
case 'purge':
|
|
645
|
-
return purgeOp(c, steps, op.mods, op.confirm === true);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
/** Виртуальный старт-шаг «от этих строк»: контекст/чтение следующего сегмента. */
|
|
649
|
-
const virtualStep = (c, row) => ({ name: row.class, cls: c.registry.resolve(row.class), filter: row.id });
|
|
650
|
-
const RawRowFmt = {
|
|
651
|
-
rows: (res) => res.map((r) => {
|
|
652
|
-
const row = toRow(r.row);
|
|
653
|
-
return r.row.depth != null ? { ...row, $depth: r.row.depth } : row;
|
|
654
|
-
}),
|
|
655
|
-
paths: (res) => res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, toRow(v)]))),
|
|
656
|
-
versions: (res) => res.map((r) => (r.row.deleted ? { ...toRow(r.row), $deleted: true } : toRow(r.row))),
|
|
657
|
-
};
|
|
607
|
+
/** Старт следующего сегмента «от этих строк»: шаг с их id (класс и роль — как у шага глагола). */
|
|
608
|
+
const fromRows = (opStep, ids, cls) => ({
|
|
609
|
+
name: opStep.name, cls: cls ?? opStep.cls, role: cls ? undefined : opStep.role, aliasKey: opStep.aliasKey,
|
|
610
|
+
filter: ids, nodeKey: nextNodeKey(),
|
|
611
|
+
});
|
|
658
612
|
/**
|
|
659
|
-
* Исполнить план
|
|
660
|
-
* (
|
|
661
|
-
*
|
|
662
|
-
* строки результата (контекст = строка); self-шаг (op сразу после op) пишет в те же строки.
|
|
663
|
-
* После delete продолжение идёт от строк КЛАССА ЦЕЛИ (замыкание каскада шире).
|
|
613
|
+
* Исполнить план в транзакции w.q: сегменты по порядку, продолжение — от результата предыдущего
|
|
614
|
+
* глагола (create и upsert — по строке, остальные — от всех строк сразу); хвост — чтение от
|
|
615
|
+
* результата последнего глагола тем же построителем, что у цепочек.
|
|
664
616
|
*/
|
|
665
|
-
|
|
666
|
-
// PURPOSE: Исполнить план целиком (сегменты операций + читающий хвост) одной транзакцией; продолжение = fan-out по строкам результата.
|
|
667
|
-
// INPUTS: { ctx: Ctx; steps: Step[]; mods: ChainMods; mode: PlanMode }
|
|
668
|
-
// OUTPUTS: { Promise<unknown> - результат в запрошенном режиме }
|
|
669
|
-
// SIDE_EFFECTS: записи + чтения в одной транзакции
|
|
670
|
-
// LINKS: M-WRITE, V-M-WRITE, M-SQL
|
|
671
|
-
// END_CONTRACT: runPlan
|
|
672
|
-
export async function runPlan(ctx, steps, mods, mode) {
|
|
617
|
+
export async function execPlan(w, steps, mods, mode, agg) {
|
|
673
618
|
const { segments, tail } = splitPlan(steps);
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const rowSteps = seg.steps[0].self
|
|
688
|
-
? [{ ...seg.steps[0], self: undefined, filter: row.id, op: undefined }]
|
|
689
|
-
: [virtualStep(c, row), ...seg.steps.map((s) => ({ ...s, op: undefined }))];
|
|
690
|
-
last.push(...await opCall(c, rowSteps, seg.op));
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
start = (seg.op.kind === 'delete' || seg.op.kind === 'purge')
|
|
694
|
-
? last.filter((r) => r.class === targetCls.id) : last;
|
|
619
|
+
let last = [];
|
|
620
|
+
let start = [];
|
|
621
|
+
let prevOp;
|
|
622
|
+
for (let i = 0; i < segments.length; i++) {
|
|
623
|
+
const seg = segments[i];
|
|
624
|
+
const opStep = seg.steps[seg.steps.length - 1];
|
|
625
|
+
const kind = opStep.op.kind;
|
|
626
|
+
const perRow = kind === 'create' || kind === 'upsert';
|
|
627
|
+
if (i === 0) {
|
|
628
|
+
last = await opCall(w, seg.steps, opStep);
|
|
629
|
+
}
|
|
630
|
+
else if (!start.length) {
|
|
631
|
+
last = [];
|
|
695
632
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (tail.length) {
|
|
701
|
-
if (!start.length)
|
|
702
|
-
return emptyFor(mode, mods);
|
|
703
|
-
const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
|
|
704
|
-
return readByMode(c, [virt, ...tail], mods, mode);
|
|
633
|
+
else if (opStep.self) {
|
|
634
|
+
// те же сущности: шаг глагола с их id вместо фильтра (create и upsert так не продолжаются — precheckPlan)
|
|
635
|
+
const same = { ...opStep, filter: start.map((r) => r.id), pivotKey: undefined, nodeKey: nextNodeKey() };
|
|
636
|
+
last = await opCall(w, [same], same);
|
|
705
637
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
return last.map((r) => r.id);
|
|
712
|
-
case 'count':
|
|
713
|
-
return last.length;
|
|
714
|
-
case 'paths':
|
|
715
|
-
return last.map((r) => ({ [keyName]: r }));
|
|
716
|
-
case 'versions':
|
|
717
|
-
case 'agg': {
|
|
718
|
-
if (!start.length)
|
|
719
|
-
return emptyFor(mode, mods);
|
|
720
|
-
const virt = { name: keyName, cls: opStep.cls, filter: start.map((r) => r.id) };
|
|
721
|
-
return readByMode(c, [virt], mods, mode);
|
|
638
|
+
else if (perRow) {
|
|
639
|
+
last = [];
|
|
640
|
+
for (const r of start) {
|
|
641
|
+
const rowSteps = [fromRows(prevOp, r.id, w.reg.resolve(r.class)), ...seg.steps];
|
|
642
|
+
last.push(...await opCall(w, rowSteps, opStep));
|
|
722
643
|
}
|
|
723
644
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
645
|
+
else {
|
|
646
|
+
last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
|
|
647
|
+
}
|
|
648
|
+
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
|
|
649
|
+
// стирания истории продолжать не от чего
|
|
650
|
+
start = kind === 'delete' && opStep.op.confirm
|
|
651
|
+
? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
|
|
652
|
+
: kind === 'purge' ? [] : last;
|
|
653
|
+
prevOp = opStep;
|
|
654
|
+
}
|
|
655
|
+
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
656
|
+
const key = opStep.aliasKey ?? opStep.name;
|
|
657
|
+
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
658
|
+
if (!start.length)
|
|
659
|
+
return { kind: 'read', q: null, res: [] };
|
|
660
|
+
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
661
|
+
return { kind: 'read', q, res };
|
|
737
662
|
}
|
|
663
|
+
return { kind: 'rows', rows: last, key };
|
|
738
664
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Батч (этап 4, п. 8): очередь планов одной транзакцией
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
/** Простая вставка: один шаг с create или upsert, без id, слотов и концов из пути. */
|
|
669
|
+
function pureKind(plan) {
|
|
670
|
+
if (plan.length !== 1)
|
|
671
|
+
return null;
|
|
672
|
+
const s = plan[0];
|
|
673
|
+
const kind = s.op?.kind;
|
|
674
|
+
if ((kind !== 'create' && kind !== 'upsert') || s.filter !== undefined || s.slots?.length || s.pivotKey !== undefined)
|
|
675
|
+
return null;
|
|
676
|
+
const props = (s.cls.schema.properties ?? {});
|
|
677
|
+
// ключ только из полей данных: id сопоставляется со входом без концов
|
|
678
|
+
if (s.cls.key.length && s.cls.key.every((k) => k in props))
|
|
679
|
+
return 'keyed';
|
|
680
|
+
return kind === 'create' && !s.cls.key.length ? 'keyless' : null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Очередь планов одной транзакцией. Подряд идущие простые вставки одного класса с ключом — одна
|
|
684
|
+
* многострочная вставка, подряд идущие upsert — тоже (повтор id режет группу: один оператор не
|
|
685
|
+
* меняет строку дважды); результат сопоставляется со входом по вычисленному id. Вставки класса без
|
|
686
|
+
* ключа уходят однострочными операторами одним конвейером: порядок строк в returning
|
|
687
|
+
* многострочной вставки не гарантирован, сопоставить не по чему.
|
|
688
|
+
*/
|
|
689
|
+
export async function execBatch(w, plans) {
|
|
690
|
+
const out = new Array(plans.length);
|
|
691
|
+
const S = quoteId(w.schema);
|
|
692
|
+
const idFor = (s) => {
|
|
693
|
+
if (!w.tenant)
|
|
694
|
+
return null;
|
|
695
|
+
try {
|
|
696
|
+
return idOf({ name: s.cls.name, key: s.cls.key, ends: [] }, s.op.data, { tenant: w.tenant });
|
|
754
697
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
return (await runQuery(c, q.text, q.params, 'ids', classes)).map((r) => r.id);
|
|
698
|
+
catch {
|
|
699
|
+
return null;
|
|
758
700
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
701
|
+
};
|
|
702
|
+
let i = 0;
|
|
703
|
+
while (i < plans.length) {
|
|
704
|
+
const kind = pureKind(plans[i]);
|
|
705
|
+
const head = plans[i][0];
|
|
706
|
+
if (kind === 'keyed' && idFor(head)) {
|
|
707
|
+
const verb = head.op.kind;
|
|
708
|
+
const group = [];
|
|
709
|
+
const seen = new Set();
|
|
710
|
+
let j = i;
|
|
711
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name) {
|
|
712
|
+
const s = plans[j][0];
|
|
713
|
+
const id = idFor(s);
|
|
714
|
+
if (!id || (verb === 'upsert' && seen.has(id)))
|
|
715
|
+
break;
|
|
716
|
+
seen.add(id);
|
|
717
|
+
group.push({ at: j, s, id });
|
|
718
|
+
j++;
|
|
719
|
+
}
|
|
720
|
+
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
721
|
+
const conflict = verb === 'upsert'
|
|
722
|
+
? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
|
|
723
|
+
: 'on conflict (id) do nothing';
|
|
724
|
+
const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
|
|
725
|
+
+ `from jsonb_to_recordset($2::jsonb) as x(d jsonb, t text[], o uuid) ${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`, [head.cls.name, input]);
|
|
726
|
+
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
727
|
+
for (const g of group) {
|
|
728
|
+
const r = byId.get(g.id);
|
|
729
|
+
// create: занятый ключ (или повтор в батче) — строка не вставлена
|
|
730
|
+
if (!r)
|
|
731
|
+
throw existsError(w, g.s.cls, g.s.op.data, {}, g.id, null);
|
|
732
|
+
const row = toRow(r.row);
|
|
733
|
+
if (verb === 'upsert')
|
|
734
|
+
row.$upsert = r.old_rev === null ? 'created' : r.old_rev === row.rev ? 'unchanged' : 'updated';
|
|
735
|
+
out[g.at] = [row];
|
|
736
|
+
}
|
|
737
|
+
i = j;
|
|
762
738
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
739
|
+
else if (kind === 'keyless') {
|
|
740
|
+
let j = i;
|
|
741
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyless')
|
|
742
|
+
j++;
|
|
743
|
+
// однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
|
|
744
|
+
const rows = await Promise.all(plans.slice(i, j).map((p) => createOp(w, p, p[0], false)));
|
|
745
|
+
rows.forEach((r, k) => (out[i + k] = r));
|
|
746
|
+
i = j;
|
|
766
747
|
}
|
|
767
|
-
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
p.steps[0].op?.kind === 'create' &&
|
|
771
|
-
noFilter(p.steps[0].filter) &&
|
|
772
|
-
typeof p.steps[0].op.data?.id !== 'string' &&
|
|
773
|
-
!Object.keys(p.steps[0].extraLinks ?? {}).length &&
|
|
774
|
-
p.steps[0].cls.idGen.version !== 5;
|
|
775
|
-
// START_CONTRACT: executeBatch
|
|
776
|
-
// PURPOSE: Исполнить очередь планов одной транзакцией; подряд идущие чистые INSERT одного класса склеиваются в multi-VALUES.
|
|
777
|
-
// INPUTS: { ctx: Ctx; queue: BatchPlan[] }
|
|
778
|
-
// OUTPUTS: { Promise<Row[][]> - результат по каждому плану }
|
|
779
|
-
// SIDE_EFFECTS: INSERT (multi/по-плану) в транзакции
|
|
780
|
-
// LINKS: M-WRITE, V-M-WRITE, M-SQL
|
|
781
|
-
// END_CONTRACT: executeBatch
|
|
782
|
-
export async function executeBatch(ctx, queue) {
|
|
783
|
-
if (!queue.length)
|
|
784
|
-
return [];
|
|
785
|
-
return inTransaction(ctx, async (c) => {
|
|
786
|
-
const results = new Array(queue.length);
|
|
787
|
-
let i = 0;
|
|
788
|
-
while (i < queue.length) {
|
|
789
|
-
// START_BLOCK_BATCH_FASTPATH
|
|
790
|
-
if (isPureInsertPlan(queue[i])) {
|
|
791
|
-
const cls = queue[i].steps[0].cls;
|
|
792
|
-
let j = i;
|
|
793
|
-
while (j < queue.length && isPureInsertPlan(queue[j]) && queue[j].steps[0].cls.id === cls.id)
|
|
794
|
-
j++;
|
|
795
|
-
const group = queue.slice(i, j);
|
|
796
|
-
const params = [];
|
|
797
|
-
for (const g of group) {
|
|
798
|
-
const s = { ...g.steps[0] };
|
|
799
|
-
aclWrite(c, s, 'WRITE'); // склейка multi-VALUES идёт мимо createOp — право+пришпиливание на каждый
|
|
800
|
-
const id = genId(cls);
|
|
801
|
-
const full = validate(cls, id, s.op?.data ?? {}, {});
|
|
802
|
-
const account = resolveAccount(c, s);
|
|
803
|
-
params.push(c.partition, id, cls.id, full, {}, tagsValue(s) ?? [], account, s.ownerFilter ?? c.owner ?? account);
|
|
804
|
-
}
|
|
805
|
-
const res = await runQuery(c, multiInsertSql(c.pgSchema, group.length), params, 'insert', [cls.id]);
|
|
806
|
-
for (let k = 0; k < group.length; k++)
|
|
807
|
-
results[i + k] = [toRow(res[k])];
|
|
808
|
-
i = j;
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
// END_BLOCK_BATCH_FASTPATH
|
|
812
|
-
results[i] = (await runPlan(c, queue[i].steps, {}, 'rows'));
|
|
748
|
+
else {
|
|
749
|
+
const r = await execPlan(w, plans[i], {}, 'rows');
|
|
750
|
+
out[i] = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
|
|
813
751
|
i++;
|
|
814
752
|
}
|
|
815
|
-
|
|
816
|
-
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
817
755
|
}
|