letopis 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +329 -278
- package/CHANGELOG.md +558 -1
- package/README.md +606 -1037
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -175
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +33 -10
- package/dist/chain.d.ts +19 -41
- package/dist/chain.js +169 -201
- package/dist/cli.js +51 -88
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -71
- package/dist/index.js +245 -130
- package/dist/internal.d.ts +12 -0
- package/dist/internal.js +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +225 -186
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +171 -215
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +26 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +5 -9
- package/dist/signals.js +5 -9
- package/dist/sql.d.ts +13 -6
- package/dist/sql.js +176 -83
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +51 -21
- package/dist/tx.d.ts +26 -1
- package/dist/tx.js +44 -14
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +23 -91
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +184 -73
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +11 -10
- package/dist/watch.js +33 -70
- package/dist/write.d.ts +9 -16
- package/dist/write.js +118 -187
- package/llms.txt +22 -25
- package/package.json +8 -11
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -667
- package/sql/70-read.sql +20 -16
- package/sql/80-auth.sql +24 -27
- package/sql/90-time.sql +70 -859
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -485
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2859
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -350
- package/dist/project.js +0 -1224
- package/sql/75-history.sql +0 -358
- package/sql/88-derived.sql +0 -1235
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/write.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Запись планами (план, §2.5, этап 4, п. 1–7, 10): глаголы create, update, upsert и delete — звенья
|
|
3
|
+
* цепочки; терминал исполняет весь план одной транзакцией. Этап 6: restore — над удалёнными объектами
|
|
4
|
+
* шага (функция базы).
|
|
5
5
|
*
|
|
6
6
|
* await db.Org(org).Staff().create({ name: 'Вася', phone: '+7' }).rows() // концы из пути: { Org }
|
|
7
7
|
* await db.Customer(c).booking({ notes: 'x' }).update({ notes: 'y' }).rows()
|
|
@@ -17,10 +17,9 @@
|
|
|
17
17
|
* строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
|
|
18
18
|
* разошёлся со { rev } — conflict.
|
|
19
19
|
*/
|
|
20
|
-
import {
|
|
20
|
+
import { lerr, ValidationError } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
|
-
import {
|
|
23
|
-
import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
|
|
22
|
+
import { accepts, buildRead, nextNodeKey, precheckStep, resolveHop, stepClasses, toRow } from './sql.js';
|
|
24
23
|
import { idOf } from './uuid.js';
|
|
25
24
|
import { applyDefaults } from './validate.js';
|
|
26
25
|
// ---------------------------------------------------------------------------
|
|
@@ -40,13 +39,10 @@ export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === tr
|
|
|
40
39
|
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
41
40
|
export function checkReason(v, where) {
|
|
42
41
|
if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
|
|
43
|
-
throw
|
|
42
|
+
throw lerr('write.bad_reason', { where });
|
|
44
43
|
}
|
|
45
44
|
return v;
|
|
46
45
|
}
|
|
47
|
-
const fail = (code, msg, detail) => {
|
|
48
|
-
throw new LetopisError(code, msg, detail);
|
|
49
|
-
};
|
|
50
46
|
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
51
47
|
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
|
|
52
48
|
/** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
|
|
@@ -54,9 +50,9 @@ export function splitPatch(patch) {
|
|
|
54
50
|
const incs = [];
|
|
55
51
|
const noMarkers = (v, path) => {
|
|
56
52
|
if (isInc(v))
|
|
57
|
-
|
|
53
|
+
throw lerr('write.inc_in_array', { path: path.join('.') });
|
|
58
54
|
if (isOp(v))
|
|
59
|
-
|
|
55
|
+
throw lerr('write.op_in_data', { path: path.join('.') });
|
|
60
56
|
if (Array.isArray(v))
|
|
61
57
|
v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
|
|
62
58
|
else if (isPlain(v))
|
|
@@ -71,13 +67,13 @@ export function splitPatch(patch) {
|
|
|
71
67
|
const p = [...path, k];
|
|
72
68
|
if (isInc(v)) {
|
|
73
69
|
if (!Number.isFinite(v.by))
|
|
74
|
-
|
|
70
|
+
throw lerr('write.inc_by_not_finite', { by: v.by, path: p.join('.') });
|
|
75
71
|
if (v.start !== undefined && !Number.isFinite(v.start))
|
|
76
|
-
|
|
72
|
+
throw lerr('write.inc_start_not_finite', { path: p.join('.') });
|
|
77
73
|
incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
|
|
78
74
|
}
|
|
79
75
|
else if (isOp(v)) {
|
|
80
|
-
|
|
76
|
+
throw lerr('write.op_in_patch', { path: p.join('.') });
|
|
81
77
|
}
|
|
82
78
|
else if (Array.isArray(v)) {
|
|
83
79
|
noMarkers(v, p);
|
|
@@ -101,15 +97,15 @@ export function splitPatch(patch) {
|
|
|
101
97
|
// ---------------------------------------------------------------------------
|
|
102
98
|
// Проверки до отправки (precheckPlan)
|
|
103
99
|
// ---------------------------------------------------------------------------
|
|
104
|
-
/** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript
|
|
105
|
-
function checkFullData(cls, data, verb
|
|
100
|
+
/** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
|
|
101
|
+
function checkFullData(cls, data, verb) {
|
|
106
102
|
if (!isPlain(data))
|
|
107
|
-
|
|
103
|
+
throw lerr('write.data_not_object', { verb });
|
|
108
104
|
const scan = (v, path) => {
|
|
109
105
|
if (isInc(v))
|
|
110
|
-
|
|
106
|
+
throw lerr('write.inc_outside_update', { verb, path: path.join('.') });
|
|
111
107
|
if (isOp(v))
|
|
112
|
-
|
|
108
|
+
throw lerr('write.op_in_verb_data', { verb, path: path.join('.') });
|
|
113
109
|
if (Array.isArray(v))
|
|
114
110
|
v.forEach((x, i) => scan(x, [...path, String(i)]));
|
|
115
111
|
else if (isPlain(v))
|
|
@@ -117,33 +113,45 @@ function checkFullData(cls, data, verb, full = true) {
|
|
|
117
113
|
scan(x, [...path, k]);
|
|
118
114
|
};
|
|
119
115
|
scan(data, []);
|
|
120
|
-
// агрегатные поля считает база (план 1.1, §2.4): переданное значение — aggregate_field до отправки, а для ранней
|
|
121
|
-
// проверки поле — 0, как его поставит база
|
|
122
|
-
const obj = data;
|
|
123
|
-
const agg = aggregateFields(cls);
|
|
124
|
-
const given = agg.filter((f) => f in obj);
|
|
125
|
-
if (given.length)
|
|
126
|
-
fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — их не передают`);
|
|
127
116
|
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
128
|
-
if (
|
|
117
|
+
if (cls.name === 'Class' || cls.abstract)
|
|
129
118
|
return;
|
|
130
|
-
const issues = cls.validator()(applyDefaults(cls.schema,
|
|
119
|
+
const issues = cls.validator()(applyDefaults(cls.schema, data));
|
|
131
120
|
if (issues.length)
|
|
132
121
|
throw new ValidationError(cls.name, issues);
|
|
133
122
|
}
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
123
|
+
/** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
|
|
124
|
+
const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
|
|
125
|
+
/**
|
|
126
|
+
* Цели глагола шага i сужены (план 2.0, этап 4, п. 2; 7.10 №2): у него или у шага до него есть фильтр, id, tags()
|
|
127
|
+
* (пустой список подходит любой строке), owner() или limit(), либо до него был create() или upsert() — дальше путь
|
|
128
|
+
* идёт от записанных строк.
|
|
129
|
+
*/
|
|
130
|
+
function narrowed(steps, i) {
|
|
131
|
+
for (let j = 0; j <= i; j++) {
|
|
132
|
+
const s = steps[j];
|
|
133
|
+
if (narrowingFilter(s.filter) || s.ownerFilter !== undefined || s.opMods?.limit !== undefined)
|
|
134
|
+
return true;
|
|
135
|
+
if (s.tagsFilter !== undefined && s.tagsFilter !== null && !(Array.isArray(s.tagsFilter) && !s.tagsFilter.length))
|
|
136
|
+
return true;
|
|
137
|
+
if (j < i && (s.op?.kind === 'create' || s.op?.kind === 'upsert'))
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
139
141
|
}
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
|
|
144
|
+
* (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
|
|
145
|
+
* агрегат чтения за последним глаголом (терминал цепочки).
|
|
146
|
+
*/
|
|
147
|
+
export function precheckPlan(reg, steps, order, agg) {
|
|
142
148
|
let prevOp;
|
|
143
149
|
for (let i = 0; i < steps.length; i++) {
|
|
144
150
|
const s = steps[i];
|
|
145
151
|
const op = s.op;
|
|
152
|
+
const tail = i === steps.length - 1;
|
|
146
153
|
if (!op) {
|
|
154
|
+
precheckStep(reg, s, tail ? order : undefined, tail ? agg : undefined);
|
|
147
155
|
prevOp = undefined;
|
|
148
156
|
continue;
|
|
149
157
|
}
|
|
@@ -152,64 +160,54 @@ export function precheckPlan(reg, steps) {
|
|
|
152
160
|
checkReason(op.reason, `${verb}()`);
|
|
153
161
|
const m = s.opMods ?? {};
|
|
154
162
|
if (m.asOf !== undefined)
|
|
155
|
-
|
|
156
|
-
// restore
|
|
157
|
-
if (m.withDeleted && verb !== 'restore'
|
|
158
|
-
|
|
163
|
+
throw lerr('write.as_of_write', { verb });
|
|
164
|
+
// restore сам ищет удалённые объекты шага
|
|
165
|
+
if (m.withDeleted && verb !== 'restore')
|
|
166
|
+
throw lerr('write.with_deleted_write', { verb });
|
|
159
167
|
switch (op.kind) {
|
|
160
168
|
case 'create':
|
|
161
169
|
case 'upsert':
|
|
162
170
|
if (s.self)
|
|
163
|
-
|
|
171
|
+
throw lerr('write.verb_after_verb', { verb, prev: prevOp?.kind });
|
|
164
172
|
if (s.pivotKey !== undefined)
|
|
165
|
-
|
|
173
|
+
throw lerr('write.create_on_pivot', { verb });
|
|
166
174
|
if (s.filter !== undefined && typeof s.filter !== 'string') {
|
|
167
|
-
|
|
175
|
+
throw lerr('write.create_with_filter', { verb, step: s.name });
|
|
168
176
|
}
|
|
169
177
|
if (op.kind === 'upsert' && !s.cls.key.length)
|
|
170
|
-
|
|
178
|
+
throw lerr('write.upsert_no_key', { cls: s.cls.name });
|
|
171
179
|
checkFullData(s.cls, op.data, verb);
|
|
172
180
|
if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
|
|
173
|
-
|
|
181
|
+
throw lerr('write.bad_tags', { verb });
|
|
174
182
|
}
|
|
175
183
|
break;
|
|
176
184
|
case 'update':
|
|
177
185
|
if (!isPlain(op.data))
|
|
178
|
-
|
|
186
|
+
throw lerr('write.patch_not_object');
|
|
179
187
|
splitPatch(op.data);
|
|
180
|
-
checkAggregatePatch(s.cls, op.data);
|
|
181
188
|
if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (!
|
|
186
|
-
|
|
187
|
-
for (const f of op.fields) {
|
|
188
|
-
const t = reg.leafType(s.cls.name, [f]);
|
|
189
|
-
if (t !== 'text')
|
|
190
|
-
fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
|
|
189
|
+
throw lerr('write.bad_rev');
|
|
190
|
+
// строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
|
|
191
|
+
// { all: true }; data.id у шага без фильтра — тоже цель (updateOp)
|
|
192
|
+
if (!op.all && !(s.filter === undefined && typeof op.data.id === 'string') && !narrowed(steps, i)) {
|
|
193
|
+
throw lerr('write.no_filter', { call: 'update()', cls: s.cls.name });
|
|
191
194
|
}
|
|
192
195
|
break;
|
|
193
|
-
}
|
|
194
|
-
case 'reclass':
|
|
195
|
-
reg.resolve(op.cls);
|
|
196
|
-
if (op.data !== undefined)
|
|
197
|
-
checkFullData(reg.resolve(op.cls), op.data, 'reclass');
|
|
198
|
-
break;
|
|
199
196
|
case 'delete':
|
|
200
|
-
|
|
201
|
-
|
|
197
|
+
// превью (без confirm) ничего не удаляет — его не сужают
|
|
198
|
+
if (op.confirm && !op.all && !narrowed(steps, i))
|
|
199
|
+
throw lerr('write.no_filter', { call: 'delete({ confirm: true })', cls: s.cls.name });
|
|
202
200
|
break;
|
|
203
|
-
case '
|
|
204
|
-
if (!s.cls.key.length)
|
|
205
|
-
fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
|
|
206
|
-
checkFullData(s.cls, op.data, 'rekey', false);
|
|
201
|
+
case 'restore':
|
|
207
202
|
break;
|
|
208
203
|
}
|
|
209
204
|
for (const sl of s.slots ?? []) {
|
|
210
205
|
if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
|
|
211
|
-
|
|
206
|
+
throw lerr('write.slot_bad_verb', { role: sl.role, verb });
|
|
212
207
|
}
|
|
208
|
+
precheckStep(reg, s, s.opMods?.order);
|
|
209
|
+
if (tail && (order !== undefined || agg !== undefined))
|
|
210
|
+
precheckStep(reg, s, order, agg);
|
|
213
211
|
prevOp = op;
|
|
214
212
|
}
|
|
215
213
|
}
|
|
@@ -225,7 +223,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
225
223
|
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
226
224
|
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
227
225
|
+ `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
|
|
228
|
-
+ `'op', ${a}.op, 'reason', ${a}.reason
|
|
226
|
+
+ `'op', ${a}.op, 'reason', ${a}.reason)`;
|
|
229
227
|
async function query(w, text, params) {
|
|
230
228
|
return (await w.q.unsafe(text, params, { prepare: true }));
|
|
231
229
|
}
|
|
@@ -251,16 +249,18 @@ export function slotRole(reg, owner, name) {
|
|
|
251
249
|
return name;
|
|
252
250
|
const c = reg.find(name);
|
|
253
251
|
if (!c)
|
|
254
|
-
|
|
252
|
+
throw lerr('write.slot_unknown', { name });
|
|
255
253
|
const roles = rolesAccepting(reg, owner, c.name);
|
|
256
254
|
if (roles.length === 1)
|
|
257
255
|
return roles[0];
|
|
258
256
|
if (!roles.length)
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
throw lerr('write.slot_not_end', { name, cls: owner.name, ends: Object.keys(owner.ends).join(', ') || '—' });
|
|
258
|
+
throw lerr('write.slot_ambiguous', { name, cls: owner.name, roles: roles.join(', ') });
|
|
261
259
|
}
|
|
262
260
|
/** Неоднозначная роль конца из пути. */
|
|
263
|
-
const ambiguous = (step, target, roles) =>
|
|
261
|
+
const ambiguous = (step, target, roles) => {
|
|
262
|
+
throw lerr('write.end_ambiguous', { cls: target.name, step: step.name, roles: roles.join(', ') });
|
|
263
|
+
};
|
|
264
264
|
/**
|
|
265
265
|
* Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
|
|
266
266
|
* класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
|
|
@@ -314,7 +314,7 @@ async function pathEnds(w, steps, taken) {
|
|
|
314
314
|
else {
|
|
315
315
|
const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
|
|
316
316
|
if (rows.length !== 1)
|
|
317
|
-
|
|
317
|
+
throw lerr('write.end_not_single', { step: s.name, cls: tcls.name, count: rows.length });
|
|
318
318
|
id = rows[0].id;
|
|
319
319
|
if (!roles) {
|
|
320
320
|
roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
|
|
@@ -343,9 +343,17 @@ async function slotIds(w, v) {
|
|
|
343
343
|
if (Array.isArray(v))
|
|
344
344
|
return v;
|
|
345
345
|
if ('plan' in v) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
let rows;
|
|
347
|
+
if (v.plan.some((s) => s.op)) {
|
|
348
|
+
// вложенный план — те же проверки до базы, что у плана терминала; с читающим хвостом после глагола execPlan
|
|
349
|
+
// отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
|
|
350
|
+
precheckPlan(w.reg, v.plan);
|
|
351
|
+
const r = await execPlan(w, v.plan, v.mods, 'rows');
|
|
352
|
+
rows = r.kind === 'rows' ? r.rows : r.res;
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
rows = await readRows(w, v.plan, v.mods);
|
|
356
|
+
}
|
|
349
357
|
return rows.map((r) => r.id);
|
|
350
358
|
}
|
|
351
359
|
return [v.id];
|
|
@@ -357,7 +365,7 @@ async function resolveSlots(w, cls, slots) {
|
|
|
357
365
|
const end = cls.ends[sl.role];
|
|
358
366
|
const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
|
|
359
367
|
if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
|
|
360
|
-
|
|
368
|
+
throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
|
|
361
369
|
}
|
|
362
370
|
out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
|
|
363
371
|
}
|
|
@@ -394,7 +402,8 @@ function existsError(w, cls, data, links, given, cause) {
|
|
|
394
402
|
id = undefined;
|
|
395
403
|
}
|
|
396
404
|
}
|
|
397
|
-
|
|
405
|
+
const detail = { id: id ?? null };
|
|
406
|
+
return id ? lerr('write.exists_id', { cls: cls.name, id }, { detail, cause }) : lerr('write.exists', { cls: cls.name }, { detail, cause });
|
|
398
407
|
}
|
|
399
408
|
const isPkConflict = (e) => {
|
|
400
409
|
const err = e;
|
|
@@ -408,16 +417,11 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
408
417
|
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
409
418
|
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
410
419
|
const owner = opStep.ownerFilter ?? w.owner ?? null;
|
|
411
|
-
|
|
412
|
-
// триггер правки возвращает прежние значения
|
|
413
|
-
const agg = aggregateFields(cls);
|
|
414
|
-
const conflict = upsert
|
|
415
|
-
? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
416
|
-
: '';
|
|
420
|
+
const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
|
|
417
421
|
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)`
|
|
418
422
|
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
419
423
|
try {
|
|
420
|
-
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner
|
|
424
|
+
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
|
|
421
425
|
return res.map((r) => {
|
|
422
426
|
const row = toRow(r.row);
|
|
423
427
|
if (upsert)
|
|
@@ -438,12 +442,17 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
438
442
|
async function explainMissing(w, missing, rev) {
|
|
439
443
|
const S = quoteId(w.schema);
|
|
440
444
|
const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
|
|
441
|
-
if (!seen.length)
|
|
442
|
-
|
|
445
|
+
if (!seen.length) {
|
|
446
|
+
throw missing.length === 1
|
|
447
|
+
? lerr('write.target_missing', { id: missing[0] }, { detail: { ids: missing } })
|
|
448
|
+
: lerr('write.targets_missing', { ids: missing.join(', ') }, { detail: { ids: missing } });
|
|
449
|
+
}
|
|
443
450
|
if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
|
|
444
|
-
|
|
451
|
+
const detail = { id: seen[0].id, rev: seen[0].rev, expected: rev };
|
|
452
|
+
throw lerr('write.rev_conflict', detail, { detail });
|
|
445
453
|
}
|
|
446
|
-
|
|
454
|
+
const ids = seen.map((r) => r.id);
|
|
455
|
+
throw lerr('write.rows_denied', { ids: ids.join(', ') }, { detail: { ids } });
|
|
447
456
|
}
|
|
448
457
|
/**
|
|
449
458
|
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
@@ -453,14 +462,14 @@ function inTargetOrder(ids, rows) {
|
|
|
453
462
|
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
454
463
|
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
455
464
|
}
|
|
456
|
-
/** Цели update
|
|
465
|
+
/** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
457
466
|
async function targetIds(w, steps, opStep, dataId) {
|
|
458
467
|
const path = steps.map(bare);
|
|
459
468
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
460
469
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
461
470
|
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
462
|
-
// без явного порядка — по id: глаголы по списку целей
|
|
463
|
-
//
|
|
471
|
+
// без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
|
|
472
|
+
// зависеть от плана чтения
|
|
464
473
|
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
465
474
|
}
|
|
466
475
|
async function updateOp(w, steps, opStep) {
|
|
@@ -472,9 +481,9 @@ async function updateOp(w, steps, opStep) {
|
|
|
472
481
|
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
473
482
|
if (op.rev !== undefined) {
|
|
474
483
|
if (ids.length > 1)
|
|
475
|
-
|
|
484
|
+
throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
|
|
476
485
|
if (!ids.length)
|
|
477
|
-
|
|
486
|
+
throw lerr('write.rev_no_target', { step: opStep.name });
|
|
478
487
|
}
|
|
479
488
|
if (!ids.length)
|
|
480
489
|
return [];
|
|
@@ -539,40 +548,13 @@ async function deleteOp(w, steps, opStep) {
|
|
|
539
548
|
}
|
|
540
549
|
return out;
|
|
541
550
|
}
|
|
542
|
-
|
|
543
|
-
const op = opStep.op;
|
|
544
|
-
const S = quoteId(w.schema);
|
|
545
|
-
const ids = await targetIds(w, steps, opStep);
|
|
546
|
-
if (!ids.length)
|
|
547
|
-
return [];
|
|
548
|
-
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), `
|
|
549
|
-
+ `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
|
|
550
|
-
+ `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
|
|
551
|
-
if (res.length < ids.length) {
|
|
552
|
-
const got = new Set(res.map((r) => r.row.id));
|
|
553
|
-
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
554
|
-
}
|
|
555
|
-
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
556
|
-
}
|
|
557
|
-
async function reclassOp(w, steps, opStep) {
|
|
558
|
-
const op = opStep.op;
|
|
559
|
-
const S = quoteId(w.schema);
|
|
560
|
-
const ids = await targetIds(w, steps, opStep);
|
|
561
|
-
if (!ids.length)
|
|
562
|
-
return [];
|
|
563
|
-
const cls = w.reg.resolve(op.cls).name;
|
|
564
|
-
// у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
|
|
565
|
-
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]);
|
|
566
|
-
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)]);
|
|
567
|
-
return res.map((r) => toRow(r.row));
|
|
568
|
-
}
|
|
569
|
-
/** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
|
|
551
|
+
/** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
|
|
570
552
|
async function deletedTargets(w, steps, opStep, verb) {
|
|
571
553
|
const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
|
|
572
554
|
const dead = rows.filter((r) => r.$deleted);
|
|
573
555
|
if (!dead.length && rows.length)
|
|
574
|
-
|
|
575
|
-
// без явного порядка — по id: restore
|
|
556
|
+
throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
|
|
557
|
+
// без явного порядка — по id: restore пишет журнал по очереди целей
|
|
576
558
|
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
577
559
|
}
|
|
578
560
|
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
@@ -583,54 +565,8 @@ async function restoreOp(w, steps, opStep) {
|
|
|
583
565
|
return [];
|
|
584
566
|
const ids = dead.map((r) => r.id);
|
|
585
567
|
await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
|
|
586
|
-
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])
|
|
587
|
-
return res.map((r) => toRow(r.row));
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
|
|
591
|
-
* версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
|
|
592
|
-
*/
|
|
593
|
-
async function purgeOp(w, steps, opStep) {
|
|
594
|
-
const op = opStep.op;
|
|
595
|
-
const S = quoteId(w.schema);
|
|
596
|
-
const dead = await deletedTargets(w, steps, opStep, 'purge');
|
|
597
|
-
const out = [];
|
|
598
|
-
for (const row of dead) {
|
|
599
|
-
const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
|
|
600
|
-
out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
|
|
601
|
-
}
|
|
602
|
-
return out;
|
|
603
|
-
}
|
|
604
|
-
/** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
|
|
605
|
-
async function rekeyOp(w, steps, opStep) {
|
|
606
|
-
const op = opStep.op;
|
|
607
|
-
const S = quoteId(w.schema);
|
|
608
|
-
const ids = await targetIds(w, steps, opStep);
|
|
609
|
-
if (!ids.length)
|
|
610
|
-
return [];
|
|
611
|
-
// поля — в данные, роли концов — в концы
|
|
612
|
-
const data = {};
|
|
613
|
-
const links = {};
|
|
614
|
-
for (const [k, v] of Object.entries(op.data)) {
|
|
615
|
-
if (opStep.cls.ends[k])
|
|
616
|
-
links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
|
|
617
|
-
else
|
|
618
|
-
data[k] = v;
|
|
619
|
-
}
|
|
620
|
-
const moved = [];
|
|
621
|
-
for (const id of ids) {
|
|
622
|
-
try {
|
|
623
|
-
const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
|
|
624
|
-
moved.push(nid);
|
|
625
|
-
}
|
|
626
|
-
catch (e) {
|
|
627
|
-
if (isPkConflict(e))
|
|
628
|
-
throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
|
|
629
|
-
throw e;
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
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]);
|
|
633
|
-
return res.map((r) => toRow(r.row));
|
|
568
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
|
|
569
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
634
570
|
}
|
|
635
571
|
async function opCall(w, steps, opStep) {
|
|
636
572
|
await useReason(w, reasonFor(w, opStep));
|
|
@@ -639,11 +575,7 @@ async function opCall(w, steps, opStep) {
|
|
|
639
575
|
case 'upsert': return createOp(w, steps, opStep, true);
|
|
640
576
|
case 'update': return updateOp(w, steps, opStep);
|
|
641
577
|
case 'delete': return deleteOp(w, steps, opStep);
|
|
642
|
-
case 'anonymize': return anonymizeOp(w, steps, opStep);
|
|
643
|
-
case 'reclass': return reclassOp(w, steps, opStep);
|
|
644
578
|
case 'restore': return restoreOp(w, steps, opStep);
|
|
645
|
-
case 'purge': return purgeOp(w, steps, opStep);
|
|
646
|
-
case 'rekey': return rekeyOp(w, steps, opStep);
|
|
647
579
|
}
|
|
648
580
|
}
|
|
649
581
|
/** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
|
|
@@ -700,18 +632,17 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
700
632
|
else {
|
|
701
633
|
last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
|
|
702
634
|
}
|
|
703
|
-
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
|
|
704
|
-
// стирания истории продолжать не от чего
|
|
635
|
+
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
|
|
705
636
|
start = kind === 'delete' && opStep.op.confirm
|
|
706
637
|
? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
|
|
707
|
-
:
|
|
638
|
+
: last;
|
|
708
639
|
prevOp = opStep;
|
|
709
640
|
}
|
|
710
641
|
// причина глагола не переходит на следующие операторы транзакции
|
|
711
642
|
await useReason(w, w.reason ?? '');
|
|
712
643
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
713
644
|
const key = opStep.aliasKey ?? opStep.name;
|
|
714
|
-
if (tail.length || mode === 'versions' || mode === 'agg'
|
|
645
|
+
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
715
646
|
if (!start.length)
|
|
716
647
|
return { kind: 'read', q: null, res: [] };
|
|
717
648
|
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
@@ -771,7 +702,9 @@ export async function execBatch(w, plans) {
|
|
|
771
702
|
&& reasonFor(w, plans[j][0]) === why) {
|
|
772
703
|
const s = plans[j][0];
|
|
773
704
|
const id = idFor(s);
|
|
774
|
-
|
|
705
|
+
// повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
|
|
706
|
+
// (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
|
|
707
|
+
if (!id || seen.has(id))
|
|
775
708
|
break;
|
|
776
709
|
seen.add(id);
|
|
777
710
|
group.push({ at: j, s, id });
|
|
@@ -779,13 +712,11 @@ export async function execBatch(w, plans) {
|
|
|
779
712
|
}
|
|
780
713
|
await useReason(w, why);
|
|
781
714
|
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
782
|
-
// агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
|
|
783
|
-
const agg = aggregateFields(head.cls);
|
|
784
715
|
const conflict = verb === 'upsert'
|
|
785
|
-
?
|
|
716
|
+
? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
|
|
786
717
|
: 'on conflict (id) do nothing';
|
|
787
718
|
const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
|
|
788
|
-
+ `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
|
|
719
|
+
+ `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]);
|
|
789
720
|
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
790
721
|
for (const g of group) {
|
|
791
722
|
const r = byId.get(g.id);
|