letopis 1.4.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.md +516 -398
- package/CHANGELOG.md +739 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +1312 -1190
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +81 -1
- package/dist/acl.js +281 -13
- package/dist/admin.d.ts +11 -141
- package/dist/admin.js +28 -186
- package/dist/auth.d.ts +20 -5
- package/dist/auth.js +80 -21
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +46 -48
- package/dist/chain.js +556 -223
- package/dist/cli.js +73 -96
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +77 -13
- package/dist/errors.js +231 -30
- package/dist/index.d.ts +62 -74
- package/dist/index.js +312 -155
- package/dist/internal.d.ts +7 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +29 -17
- package/dist/load.js +235 -190
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +194 -225
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +237 -0
- package/dist/messages/api.js +304 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +98 -0
- package/dist/messages/index.js +121 -0
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +256 -0
- package/dist/messages/model.js +314 -0
- package/dist/messages/query.d.ts +141 -0
- package/dist/messages/query.js +171 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +164 -0
- package/dist/messages/sql-core.js +198 -0
- package/dist/messages/sql-time.d.ts +86 -0
- package/dist/messages/sql-time.js +111 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +452 -0
- package/dist/messages/write.js +572 -0
- package/dist/model.d.ts +77 -60
- package/dist/model.js +43 -34
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +9 -1
- package/dist/sql.js +175 -107
- package/dist/sync.d.ts +10 -4
- package/dist/sync.js +90 -17
- package/dist/tx.d.ts +21 -2
- package/dist/tx.js +76 -13
- package/dist/typed.d.ts +252 -53
- package/dist/types.d.ts +93 -90
- package/dist/types.js +17 -14
- package/dist/up.d.ts +56 -35
- package/dist/up.js +229 -83
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +14 -6
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +82 -33
- package/dist/watch.d.ts +10 -16
- package/dist/watch.js +36 -77
- package/dist/write.d.ts +87 -17
- package/dist/write.js +441 -206
- package/llms.txt +48 -31
- package/package.json +13 -14
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +18 -9
- package/sql/30-validate.sql +79 -30
- package/sql/40-class.sql +41 -115
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +109 -42
- package/sql/90-time.sql +108 -947
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +6 -1
- package/sql/README.md +32 -24
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +18 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/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,12 +17,11 @@
|
|
|
17
17
|
* строка видна, но править нельзя — acl_denied; строки уже нет — target_not_found; номер версии
|
|
18
18
|
* разошёлся со { rev } — conflict.
|
|
19
19
|
*/
|
|
20
|
-
import { LetopisError, ValidationError } from './errors.js';
|
|
20
|
+
import { fromDbError, issuesFromDb, lerr, lmsg, LetopisError, 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
|
-
import { applyDefaults } from './validate.js';
|
|
24
|
+
import { applyDefaults, leavesToDb } from './validate.js';
|
|
26
25
|
// ---------------------------------------------------------------------------
|
|
27
26
|
// Приращение: маркер, которого не получить из JSON (§2.5)
|
|
28
27
|
// ---------------------------------------------------------------------------
|
|
@@ -37,26 +36,48 @@ export function inc(by, opts = {}) {
|
|
|
37
36
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
38
37
|
}
|
|
39
38
|
export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
|
|
39
|
+
/**
|
|
40
|
+
* Данные create() и upsert() без ключей со значением undefined — как у патча update() (splitPatch): ключ undefined JSON
|
|
41
|
+
* не передаёт, а валидатор на TypeScript принимал его за поле не того типа (план 2.1, A12). Вложенные объекты — тоже;
|
|
42
|
+
* массивы, даты, маркеры и операторы — как есть.
|
|
43
|
+
*/
|
|
44
|
+
export function dropUndefined(data) {
|
|
45
|
+
if (!isPlain(data))
|
|
46
|
+
return data;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [k, v] of Object.entries(data)) {
|
|
49
|
+
if (v === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
out[k] = isPlain(v) ? dropUndefined(v) : v;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** expect — список строк { id, rev } (и $action у превью удаления): иначе invalid_query до записи. */
|
|
56
|
+
function checkExpect(expect, verb) {
|
|
57
|
+
const ok = Array.isArray(expect) && expect.every((x) => x !== null && typeof x === 'object' && typeof x.id === 'string'
|
|
58
|
+
&& Number.isInteger(x.rev) && x.rev >= 1);
|
|
59
|
+
if (!ok)
|
|
60
|
+
throw lerr('write.bad_expect', { verb });
|
|
61
|
+
}
|
|
40
62
|
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
41
63
|
export function checkReason(v, where) {
|
|
42
64
|
if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
|
|
43
|
-
throw
|
|
65
|
+
throw lerr('write.bad_reason', { where });
|
|
44
66
|
}
|
|
45
67
|
return v;
|
|
46
68
|
}
|
|
47
|
-
const fail = (code, msg, detail) => {
|
|
48
|
-
throw new LetopisError(code, msg, detail);
|
|
49
|
-
};
|
|
50
69
|
const asList = (v) => (Array.isArray(v) ? v : [v]);
|
|
51
|
-
|
|
70
|
+
function isPlain(v) {
|
|
71
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) && !(v instanceof Date) && !isInc(v) && !isOp(v);
|
|
72
|
+
}
|
|
52
73
|
/** Патч update → остаток для merge и список приращений. Недопустимое — invalid_data до отправки. */
|
|
53
74
|
export function splitPatch(patch) {
|
|
54
75
|
const incs = [];
|
|
55
76
|
const noMarkers = (v, path) => {
|
|
56
77
|
if (isInc(v))
|
|
57
|
-
|
|
78
|
+
throw lerr('write.inc_in_array', { path: path.join('.') });
|
|
58
79
|
if (isOp(v))
|
|
59
|
-
|
|
80
|
+
throw lerr('write.op_in_data', { path: path.join('.') });
|
|
60
81
|
if (Array.isArray(v))
|
|
61
82
|
v.forEach((x, i) => noMarkers(x, [...path, String(i)]));
|
|
62
83
|
else if (isPlain(v))
|
|
@@ -71,13 +92,13 @@ export function splitPatch(patch) {
|
|
|
71
92
|
const p = [...path, k];
|
|
72
93
|
if (isInc(v)) {
|
|
73
94
|
if (!Number.isFinite(v.by))
|
|
74
|
-
|
|
95
|
+
throw lerr('write.inc_by_not_finite', { by: v.by, path: p.join('.') });
|
|
75
96
|
if (v.start !== undefined && !Number.isFinite(v.start))
|
|
76
|
-
|
|
97
|
+
throw lerr('write.inc_start_not_finite', { path: p.join('.') });
|
|
77
98
|
incs.push({ path: p, by: v.by, ...(v.start !== undefined ? { start: v.start } : {}) });
|
|
78
99
|
}
|
|
79
100
|
else if (isOp(v)) {
|
|
80
|
-
|
|
101
|
+
throw lerr('write.op_in_patch', { path: p.join('.') });
|
|
81
102
|
}
|
|
82
103
|
else if (Array.isArray(v)) {
|
|
83
104
|
noMarkers(v, p);
|
|
@@ -101,15 +122,60 @@ export function splitPatch(patch) {
|
|
|
101
122
|
// ---------------------------------------------------------------------------
|
|
102
123
|
// Проверки до отправки (precheckPlan)
|
|
103
124
|
// ---------------------------------------------------------------------------
|
|
104
|
-
/**
|
|
105
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Нарушения, найденные валидатором на TypeScript у класса с правилами только для базы (`pattern`): полный список даст
|
|
127
|
+
* база (completeIssues). Ключ — сама ошибка.
|
|
128
|
+
*/
|
|
129
|
+
const PENDING = new WeakMap();
|
|
130
|
+
/**
|
|
131
|
+
* Ключи данных, которые не поля класса, а его концы (связи): имя роли конца или имя класса на конце (у роли с другим
|
|
132
|
+
* именем). Пары [ключ, роль] — для текста «задайте .Роль.set(id)» (write.link_in_data, 3.0): подсказка в самом тексте
|
|
133
|
+
* ошибки, а не только в err.hint.
|
|
134
|
+
*/
|
|
135
|
+
function endsAsFields(cls, data, skip) {
|
|
136
|
+
const props = (cls.schema.properties ?? {});
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const k of Object.keys(data)) {
|
|
139
|
+
if (data[k] === undefined || k in props || k === skip)
|
|
140
|
+
continue;
|
|
141
|
+
if (cls.ends[k] !== undefined) {
|
|
142
|
+
out.push([k, k]);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const role = Object.entries(cls.ends).find(([, e]) => (Array.isArray(e.class) ? e.class : [e.class]).includes(k))?.[0];
|
|
146
|
+
if (role !== undefined)
|
|
147
|
+
out.push([k, role]);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Ключ и параметры ошибки «конец передан полем данных» (write.link_in_data). */
|
|
152
|
+
function linkInData(cls, verb, found) {
|
|
153
|
+
return { key: 'write.link_in_data', params: { verb, cls: cls.name, keys: found.map(([k]) => k).join(', '), role: found[0][1] } };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Патч update() с ключом — именем конца класса, которого нет среди полей: база отклонила бы его (лишний ключ верхнего
|
|
157
|
+
* уровня) — отказ до записи с подсказкой «конец — из пути или .Роль.set()» (план 2.1, A8). data.id цели — не данные.
|
|
158
|
+
*/
|
|
159
|
+
function checkPatchEnds(s, patch) {
|
|
160
|
+
const cls = s.cls;
|
|
161
|
+
if (cls.name === 'Class' || cls.abstract || cls.schema.additionalProperties !== false)
|
|
162
|
+
return;
|
|
163
|
+
const useDataId = s.filter === undefined && typeof patch.id === 'string';
|
|
164
|
+
const found = endsAsFields(cls, patch, useDataId ? 'id' : undefined);
|
|
165
|
+
if (!found.length)
|
|
166
|
+
return;
|
|
167
|
+
const issue = { path: [], keyword: 'additionalProperties', message: lmsg('model.issue_additional', { keys: found.map(([k]) => k).join(', ') }) };
|
|
168
|
+
throw new ValidationError(cls.name, [issue], linkInData(cls, 'update', found));
|
|
169
|
+
}
|
|
170
|
+
/** Данные create/upsert: объект без маркеров; ранняя проверка валидатором на TypeScript. */
|
|
171
|
+
function checkFullData(cls, data, verb) {
|
|
106
172
|
if (!isPlain(data))
|
|
107
|
-
|
|
173
|
+
throw lerr('write.data_not_object', { verb });
|
|
108
174
|
const scan = (v, path) => {
|
|
109
175
|
if (isInc(v))
|
|
110
|
-
|
|
176
|
+
throw lerr('write.inc_outside_update', { verb, path: path.join('.') });
|
|
111
177
|
if (isOp(v))
|
|
112
|
-
|
|
178
|
+
throw lerr('write.op_in_verb_data', { verb, path: path.join('.') });
|
|
113
179
|
if (Array.isArray(v))
|
|
114
180
|
v.forEach((x, i) => scan(x, [...path, String(i)]));
|
|
115
181
|
else if (isPlain(v))
|
|
@@ -117,33 +183,100 @@ function checkFullData(cls, data, verb, full = true) {
|
|
|
117
183
|
scan(x, [...path, k]);
|
|
118
184
|
};
|
|
119
185
|
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
186
|
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
128
|
-
if (
|
|
187
|
+
if (cls.name === 'Class' || cls.abstract)
|
|
129
188
|
return;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
189
|
+
const v = cls.validator();
|
|
190
|
+
const full = applyDefaults(cls.schema, data);
|
|
191
|
+
const issues = v(full);
|
|
192
|
+
if (!issues.length)
|
|
193
|
+
return;
|
|
194
|
+
const found = endsAsFields(cls, data);
|
|
195
|
+
const e = new ValidationError(cls.name, issues, found.length ? linkInData(cls, verb, found) : undefined);
|
|
196
|
+
if (leavesToDb(v))
|
|
197
|
+
PENDING.set(e, { cls: cls.name, data: full });
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Ошибка данных до записи у класса с правилами только для базы (`pattern`): валидатор на TypeScript нашёл не всё — полный
|
|
202
|
+
* список нарушений даёт база одним запросом (`validate_row`, план 2.1, A8), под точкой сохранения: неудача запроса
|
|
203
|
+
* (схема прежней ревизии) не ломает db.begin(), остаётся прежняя ошибка. Прочие ошибки — как есть.
|
|
204
|
+
*/
|
|
205
|
+
export async function completeIssues(ctx, e) {
|
|
206
|
+
const p = e instanceof ValidationError ? PENDING.get(e) : undefined;
|
|
207
|
+
if (!p)
|
|
208
|
+
return e;
|
|
209
|
+
const S = quoteId(ctx.schema);
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = await ctx.exec.run(async (q) => {
|
|
213
|
+
await q.unsafe('savepoint letopis_issues');
|
|
214
|
+
try {
|
|
215
|
+
const [r] = (await q.unsafe(`select ${S}.validate_row($1::text, $2::jsonb) as v`, [p.cls, p.data]));
|
|
216
|
+
await q.unsafe('release savepoint letopis_issues');
|
|
217
|
+
return r?.v;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
await q.unsafe('rollback to savepoint letopis_issues');
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}, { read: true });
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return e;
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(raw) || !raw.length)
|
|
229
|
+
return e;
|
|
230
|
+
const v = e;
|
|
231
|
+
return Object.assign(new ValidationError(p.cls, issuesFromDb(raw), v.key ? { key: v.key, params: v.params } : undefined), { cause: e });
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Блокировка строк в порядке id (lockedRead, updateOp — for no key update; deleteOp с expect — for update): id, которые
|
|
235
|
+
* удалось заблокировать.
|
|
236
|
+
*/
|
|
237
|
+
export async function lockIds(q, S, ids, mode = 'no key update') {
|
|
238
|
+
const rows = (await q.unsafe(`select id from ${S}.entity where id = any ($1::uuid[]) order by id for ${mode}`, [ids], { prepare: true }));
|
|
239
|
+
return new Set(rows.map((r) => r.id));
|
|
240
|
+
}
|
|
241
|
+
/** Фильтр шага сужает цели: id, список id, оператор, строка ответа, объект хотя бы с одним заданным полем. */
|
|
242
|
+
const narrowingFilter = (f) => f !== undefined && f !== null && (typeof f !== 'object' || Array.isArray(f) || isOp(f) || Object.values(f).some((v) => v !== undefined));
|
|
243
|
+
/**
|
|
244
|
+
* Цели глагола шага i сужены (план 2.0, этап 4, п. 2; 7.10 №2): у него или у шага до него есть фильтр, id, tags()
|
|
245
|
+
* (пустой список подходит любой строке), owner() или limit(), либо до него был create() или upsert() — дальше путь
|
|
246
|
+
* идёт от записанных строк.
|
|
247
|
+
*/
|
|
248
|
+
function narrowed(steps, i) {
|
|
249
|
+
for (let j = 0; j <= i; j++) {
|
|
250
|
+
const s = steps[j];
|
|
251
|
+
if (narrowingFilter(s.filter) || s.ownerFilter !== undefined || s.opMods?.limit !== undefined)
|
|
252
|
+
return true;
|
|
253
|
+
if (s.tagsFilter !== undefined && s.tagsFilter !== null && !(Array.isArray(s.tagsFilter) && !s.tagsFilter.length))
|
|
254
|
+
return true;
|
|
255
|
+
if (j < i && (s.op?.kind === 'create' || s.op?.kind === 'upsert'))
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
/** Шаги сегмента глагола шага i: от шага после прежнего глагола до него самого. */
|
|
261
|
+
function segmentOf(steps, i) {
|
|
262
|
+
let j = i - 1;
|
|
263
|
+
while (j >= 0 && !steps[j].op)
|
|
264
|
+
j--;
|
|
265
|
+
return steps.slice(j + 1, i + 1);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Всё, что проверяется без базы, — до отправки первого оператора (§2.5). Фильтры шагов и поля sort() и агрегатов
|
|
269
|
+
* (неизвестное поле — invalid_query, план 2.0, этап 3, п. 3) — precheckStep построителя; order и agg — модификатор и
|
|
270
|
+
* агрегат чтения за последним глаголом (терминал цепочки).
|
|
271
|
+
*/
|
|
272
|
+
export function precheckPlan(reg, steps, order, agg) {
|
|
142
273
|
let prevOp;
|
|
143
274
|
for (let i = 0; i < steps.length; i++) {
|
|
144
275
|
const s = steps[i];
|
|
145
276
|
const op = s.op;
|
|
277
|
+
const tail = i === steps.length - 1;
|
|
146
278
|
if (!op) {
|
|
279
|
+
precheckStep(reg, s, tail ? order : undefined, tail ? agg : undefined);
|
|
147
280
|
prevOp = undefined;
|
|
148
281
|
continue;
|
|
149
282
|
}
|
|
@@ -152,67 +285,73 @@ export function precheckPlan(reg, steps) {
|
|
|
152
285
|
checkReason(op.reason, `${verb}()`);
|
|
153
286
|
const m = s.opMods ?? {};
|
|
154
287
|
if (m.asOf !== undefined)
|
|
155
|
-
|
|
156
|
-
// restore
|
|
157
|
-
|
|
158
|
-
|
|
288
|
+
throw lerr('write.as_of_write', { verb });
|
|
289
|
+
// restore сам ищет удалённые объекты шага; withDeleted() — модификатор шага (план 2.1, A9): у шага глагола или шага
|
|
290
|
+
// до него в сегменте — отказ, как прежде у модификатора цепочки
|
|
291
|
+
if (verb !== 'restore' && segmentOf(steps, i).some((x) => x.withDeleted))
|
|
292
|
+
throw lerr('write.with_deleted_write', { verb });
|
|
159
293
|
switch (op.kind) {
|
|
160
294
|
case 'create':
|
|
161
295
|
case 'upsert':
|
|
162
296
|
if (s.self)
|
|
163
|
-
|
|
297
|
+
throw lerr('write.verb_after_verb', { verb, prev: prevOp?.kind });
|
|
164
298
|
if (s.pivotKey !== undefined)
|
|
165
|
-
|
|
299
|
+
throw lerr('write.create_on_pivot', { verb });
|
|
166
300
|
if (s.filter !== undefined && typeof s.filter !== 'string') {
|
|
167
|
-
|
|
301
|
+
throw lerr('write.create_with_filter', { verb, step: s.name });
|
|
168
302
|
}
|
|
169
303
|
if (op.kind === 'upsert' && !s.cls.key.length)
|
|
170
|
-
|
|
304
|
+
throw lerr('write.upsert_no_key', { cls: s.cls.name });
|
|
171
305
|
checkFullData(s.cls, op.data, verb);
|
|
172
306
|
if (s.tagsFilter !== undefined && typeof s.tagsFilter !== 'string' && !Array.isArray(s.tagsFilter)) {
|
|
173
|
-
|
|
307
|
+
throw lerr('write.bad_tags', { verb });
|
|
174
308
|
}
|
|
175
309
|
break;
|
|
176
310
|
case 'update':
|
|
177
311
|
if (!isPlain(op.data))
|
|
178
|
-
|
|
312
|
+
throw lerr('write.patch_not_object');
|
|
179
313
|
splitPatch(op.data);
|
|
180
|
-
|
|
314
|
+
checkPatchEnds(s, op.data);
|
|
181
315
|
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
|
-
// поля нет в классе — так и сказано, а не «поле jsonb» (план 2.0, 7.5 №3)
|
|
189
|
-
if (!(f in (s.cls.schema.properties ?? {})))
|
|
190
|
-
fail('invalid_query', `anonymize(): поля ${f} нет в классе ${s.cls.name}`);
|
|
191
|
-
const t = reg.leafType(s.cls.name, [f]);
|
|
192
|
-
if (t !== 'text')
|
|
193
|
-
fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
|
|
316
|
+
throw lerr('write.bad_rev');
|
|
317
|
+
// строгий режим (план 2.0, этап 4, п. 2; 7.10 №2): правка без сужения целей — отказ до записи, все строки —
|
|
318
|
+
// { all: true }; data.id у шага без фильтра — тоже цель (updateOp)
|
|
319
|
+
if (!op.all && !(s.filter === undefined && typeof op.data.id === 'string') && !narrowed(steps, i)) {
|
|
320
|
+
throw lerr('write.no_filter', { call: 'update()', cls: s.cls.name });
|
|
194
321
|
}
|
|
195
322
|
break;
|
|
196
|
-
}
|
|
197
|
-
case 'reclass':
|
|
198
|
-
reg.resolve(op.cls);
|
|
199
|
-
if (op.data !== undefined)
|
|
200
|
-
checkFullData(reg.resolve(op.cls), op.data, 'reclass');
|
|
201
|
-
break;
|
|
202
323
|
case 'delete':
|
|
203
|
-
|
|
204
|
-
|
|
324
|
+
// превью (без confirm) ничего не удаляет — его не сужают и не сверяют
|
|
325
|
+
if (op.expect !== undefined) {
|
|
326
|
+
if (!op.confirm)
|
|
327
|
+
throw lerr('write.expect_without_confirm');
|
|
328
|
+
checkExpect(op.expect, verb);
|
|
329
|
+
}
|
|
330
|
+
// 3.0: удаление сверяется с превью, которое видел пользователь (expect); без сверки — только осознанно, { all: true }
|
|
331
|
+
if (op.confirm && op.expect === undefined && !op.all)
|
|
332
|
+
throw lerr('write.confirm_without_expect');
|
|
333
|
+
if (op.confirm && !op.all && !narrowed(steps, i))
|
|
334
|
+
throw lerr('write.no_filter', { call: 'delete({ confirm: true, expect })', cls: s.cls.name });
|
|
205
335
|
break;
|
|
206
|
-
case '
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
336
|
+
case 'restore':
|
|
337
|
+
// partial (второй раунд 2.1, A3) — только с expect: пропуск считается по показанным надгробиям
|
|
338
|
+
if (op.partial && op.expect === undefined)
|
|
339
|
+
throw lerr('write.partial_without_expect');
|
|
340
|
+
if (op.expect !== undefined)
|
|
341
|
+
checkExpect(op.expect, verb);
|
|
342
|
+
// 3.0: без expect — отказ, и для явных id тоже: restore вернул бы последнее надгробие, а не то, что видел
|
|
343
|
+
// пользователь (объект могли восстановить, поменять и удалить снова); вернуть всё без сверки — { all: true }
|
|
344
|
+
else if (!op.all)
|
|
345
|
+
throw lerr('write.restore_without_expect');
|
|
210
346
|
break;
|
|
211
347
|
}
|
|
212
348
|
for (const sl of s.slots ?? []) {
|
|
213
349
|
if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
|
|
214
|
-
|
|
350
|
+
throw lerr('write.slot_bad_verb', { role: sl.role, verb });
|
|
215
351
|
}
|
|
352
|
+
precheckStep(reg, s, s.opMods?.order);
|
|
353
|
+
if (tail && (order !== undefined || agg !== undefined))
|
|
354
|
+
precheckStep(reg, s, order, agg);
|
|
216
355
|
prevOp = op;
|
|
217
356
|
}
|
|
218
357
|
}
|
|
@@ -228,7 +367,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
228
367
|
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
229
368
|
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
230
369
|
+ `'links', ${a}.links, 'data', ${a}.data, 'tags', ${a}.tags, 'at', ${a}.at, 'author', ${a}.author, 'agent', ${a}.agent, `
|
|
231
|
-
+ `'op', ${a}.op, 'reason', ${a}.reason
|
|
370
|
+
+ `'op', ${a}.op, 'reason', ${a}.reason)`;
|
|
232
371
|
async function query(w, text, params) {
|
|
233
372
|
return (await w.q.unsafe(text, params, { prepare: true }));
|
|
234
373
|
}
|
|
@@ -254,16 +393,18 @@ export function slotRole(reg, owner, name) {
|
|
|
254
393
|
return name;
|
|
255
394
|
const c = reg.find(name);
|
|
256
395
|
if (!c)
|
|
257
|
-
|
|
396
|
+
throw lerr('write.slot_unknown', { name });
|
|
258
397
|
const roles = rolesAccepting(reg, owner, c.name);
|
|
259
398
|
if (roles.length === 1)
|
|
260
399
|
return roles[0];
|
|
261
400
|
if (!roles.length)
|
|
262
|
-
|
|
263
|
-
|
|
401
|
+
throw lerr('write.slot_not_end', { name, cls: owner.name, ends: Object.keys(owner.ends).join(', ') || '—' });
|
|
402
|
+
throw lerr('write.slot_ambiguous', { name, cls: owner.name, roles: roles.join(', ') });
|
|
264
403
|
}
|
|
265
404
|
/** Неоднозначная роль конца из пути. */
|
|
266
|
-
const ambiguous = (step, target, roles) =>
|
|
405
|
+
const ambiguous = (step, target, roles) => {
|
|
406
|
+
throw lerr('write.end_ambiguous', { cls: target.name, step: step.name, roles: roles.join(', ') });
|
|
407
|
+
};
|
|
267
408
|
/**
|
|
268
409
|
* Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
|
|
269
410
|
* класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
|
|
@@ -317,7 +458,7 @@ async function pathEnds(w, steps, taken) {
|
|
|
317
458
|
else {
|
|
318
459
|
const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
|
|
319
460
|
if (rows.length !== 1)
|
|
320
|
-
|
|
461
|
+
throw lerr('write.end_not_single', { step: s.name, cls: tcls.name, count: rows.length });
|
|
321
462
|
id = rows[0].id;
|
|
322
463
|
if (!roles) {
|
|
323
464
|
roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
|
|
@@ -352,7 +493,8 @@ async function slotIds(w, v) {
|
|
|
352
493
|
// отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
|
|
353
494
|
precheckPlan(w.reg, v.plan);
|
|
354
495
|
const r = await execPlan(w, v.plan, v.mods, 'rows');
|
|
355
|
-
|
|
496
|
+
// чтение хвоста отдаёт строки в обёртке { row } (readIn, режим 'rows') — как в батче, распаковать
|
|
497
|
+
rows = r.kind === 'rows' ? r.rows : r.res.map((x) => toRow(x.row));
|
|
356
498
|
}
|
|
357
499
|
else {
|
|
358
500
|
rows = await readRows(w, v.plan, v.mods);
|
|
@@ -368,7 +510,7 @@ async function resolveSlots(w, cls, slots) {
|
|
|
368
510
|
const end = cls.ends[sl.role];
|
|
369
511
|
const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
|
|
370
512
|
if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
|
|
371
|
-
|
|
513
|
+
throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
|
|
372
514
|
}
|
|
373
515
|
out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
|
|
374
516
|
}
|
|
@@ -405,7 +547,8 @@ function existsError(w, cls, data, links, given, cause) {
|
|
|
405
547
|
id = undefined;
|
|
406
548
|
}
|
|
407
549
|
}
|
|
408
|
-
|
|
550
|
+
const detail = { id: id ?? null };
|
|
551
|
+
return id ? lerr('write.exists_id', { cls: cls.name, id }, { detail, cause }) : lerr('write.exists', { cls: cls.name }, { detail, cause });
|
|
409
552
|
}
|
|
410
553
|
const isPkConflict = (e) => {
|
|
411
554
|
const err = e;
|
|
@@ -419,16 +562,11 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
419
562
|
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
420
563
|
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
421
564
|
const owner = opStep.ownerFilter ?? w.owner ?? null;
|
|
422
|
-
|
|
423
|
-
// триггер правки возвращает прежние значения
|
|
424
|
-
const agg = aggregateFields(cls);
|
|
425
|
-
const conflict = upsert
|
|
426
|
-
? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
427
|
-
: '';
|
|
565
|
+
const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
|
|
428
566
|
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)`
|
|
429
567
|
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
430
568
|
try {
|
|
431
|
-
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner
|
|
569
|
+
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
|
|
432
570
|
return res.map((r) => {
|
|
433
571
|
const row = toRow(r.row);
|
|
434
572
|
if (upsert)
|
|
@@ -449,12 +587,17 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
449
587
|
async function explainMissing(w, missing, rev) {
|
|
450
588
|
const S = quoteId(w.schema);
|
|
451
589
|
const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
|
|
452
|
-
if (!seen.length)
|
|
453
|
-
|
|
590
|
+
if (!seen.length) {
|
|
591
|
+
throw missing.length === 1
|
|
592
|
+
? lerr('write.target_missing', { id: missing[0] }, { detail: { ids: missing } })
|
|
593
|
+
: lerr('write.targets_missing', { ids: missing.join(', ') }, { detail: { ids: missing } });
|
|
594
|
+
}
|
|
454
595
|
if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
|
|
455
|
-
|
|
596
|
+
const detail = { id: seen[0].id, rev: seen[0].rev, expected: rev };
|
|
597
|
+
throw lerr('write.rev_conflict', detail, { detail });
|
|
456
598
|
}
|
|
457
|
-
|
|
599
|
+
const ids = seen.map((r) => r.id);
|
|
600
|
+
throw lerr('write.rows_denied', { ids: ids.join(', ') }, { detail: { ids } });
|
|
458
601
|
}
|
|
459
602
|
/**
|
|
460
603
|
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
@@ -464,14 +607,19 @@ function inTargetOrder(ids, rows) {
|
|
|
464
607
|
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
465
608
|
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
466
609
|
}
|
|
467
|
-
/**
|
|
610
|
+
/** Шаги сужены только по id: фильтр — id или список id, без tags(), owner() и обхода deep(). */
|
|
611
|
+
function idsOnly(steps) {
|
|
612
|
+
return steps.every((s) => (s.filter === undefined || typeof s.filter === 'string' || (Array.isArray(s.filter) && s.filter.every((x) => typeof x === 'string')))
|
|
613
|
+
&& s.tagsFilter === undefined && s.ownerFilter === undefined);
|
|
614
|
+
}
|
|
615
|
+
/** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
468
616
|
async function targetIds(w, steps, opStep, dataId) {
|
|
469
617
|
const path = steps.map(bare);
|
|
470
618
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
471
619
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
472
620
|
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
473
|
-
// без явного порядка — по id: глаголы по списку целей
|
|
474
|
-
//
|
|
621
|
+
// без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
|
|
622
|
+
// зависеть от плана чтения
|
|
475
623
|
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
476
624
|
}
|
|
477
625
|
async function updateOp(w, steps, opStep) {
|
|
@@ -480,12 +628,20 @@ async function updateOp(w, steps, opStep) {
|
|
|
480
628
|
// data.id — цель, только если у шага нет своего фильтра (write.ts:461-466 в 0.21); тогда id не данные
|
|
481
629
|
const useDataId = opStep.filter === undefined && typeof op.data.id === 'string';
|
|
482
630
|
const { id: dataId, ...data } = op.data;
|
|
483
|
-
|
|
631
|
+
let ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
632
|
+
// цели сужены не только по id (данные, концы, tags(), owner()): как lockedRead — блокировка целей и то же чтение заново;
|
|
633
|
+
// правятся только строки, которые и после ожидания блокировки подходят под условия (план 2.1, A4). Без гонки — те же
|
|
634
|
+
// цели; строка, которую не дала заблокировать RLS, остаётся в целях — её объяснит explainMissing
|
|
635
|
+
if (ids.length && !idsOnly(steps)) {
|
|
636
|
+
const locked = await lockIds(w.q, S, ids);
|
|
637
|
+
const fresh = new Set(await targetIds(w, steps, opStep, useDataId ? dataId : undefined));
|
|
638
|
+
ids = ids.filter((id) => fresh.has(id) || !locked.has(id));
|
|
639
|
+
}
|
|
484
640
|
if (op.rev !== undefined) {
|
|
485
641
|
if (ids.length > 1)
|
|
486
|
-
|
|
642
|
+
throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
|
|
487
643
|
if (!ids.length)
|
|
488
|
-
|
|
644
|
+
throw lerr('write.rev_no_target', { step: opStep.name });
|
|
489
645
|
}
|
|
490
646
|
if (!ids.length)
|
|
491
647
|
return [];
|
|
@@ -510,24 +666,53 @@ async function updateOp(w, steps, opStep) {
|
|
|
510
666
|
}
|
|
511
667
|
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
512
668
|
}
|
|
669
|
+
/** Превью удаления целей: то же замыкание, что у триггера удаления (ref_action), база не меняется. */
|
|
670
|
+
async function previewOf(w, ids) {
|
|
671
|
+
const S = quoteId(w.schema);
|
|
672
|
+
const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
|
|
673
|
+
+ `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
|
|
674
|
+
return res.map((r) => {
|
|
675
|
+
const row = toRow(r.row);
|
|
676
|
+
row.$action = r.action;
|
|
677
|
+
if (r.depth !== null)
|
|
678
|
+
row.$depth = r.depth;
|
|
679
|
+
return row;
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
/** Превью совпадает с показанным: те же строки по id, с теми же rev и $action (порядок не важен). */
|
|
683
|
+
function samePreview(now, expect) {
|
|
684
|
+
if (now.length !== expect.length)
|
|
685
|
+
return false;
|
|
686
|
+
const want = new Map(expect.map((x) => [x.id.toLowerCase(), x]));
|
|
687
|
+
return now.every((r) => {
|
|
688
|
+
const x = want.get(r.id.toLowerCase());
|
|
689
|
+
return x !== undefined && x.rev === r.rev && (x.$action === undefined || x.$action === r.$action);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
513
692
|
async function deleteOp(w, steps, opStep) {
|
|
514
693
|
const op = opStep.op;
|
|
515
694
|
const S = quoteId(w.schema);
|
|
516
695
|
const ids = await targetIds(w, steps, opStep);
|
|
696
|
+
if (!op.confirm)
|
|
697
|
+
return ids.length ? previewOf(w, ids) : [];
|
|
698
|
+
// delete({ confirm: true, expect }): удалить ровно показанное (план 2.1, A2) — превью заново в той же транзакции.
|
|
699
|
+
// Атомарно (второй раунд 2.1, A1): всё замыкание каскада (строки превью с $action 'delete') блокируется for update в
|
|
700
|
+
// порядке id, и превью строится ещё раз уже под блокировкой. Вставка связи берёт на своих концах for key share
|
|
701
|
+
// (60-write.sql, targets_bad): с for update она конфликтует, с for no key update — нет. Вставщик успел первым —
|
|
702
|
+
// пересчёт видит его строку (preview_changed); мы первые — он получает end_target_missing. Взаимоблокировку при росте
|
|
703
|
+
// замыкания повторяет runTx (40P01)
|
|
704
|
+
if (op.expect !== undefined) {
|
|
705
|
+
let now = ids.length ? await previewOf(w, ids) : [];
|
|
706
|
+
const closure = now.filter((r) => r.$action === 'delete').map((r) => r.id);
|
|
707
|
+
if (closure.length) {
|
|
708
|
+
await lockIds(w.q, S, closure, 'update');
|
|
709
|
+
now = await previewOf(w, ids);
|
|
710
|
+
}
|
|
711
|
+
if (!samePreview(now, op.expect))
|
|
712
|
+
throw lerr('write.preview_changed', undefined, { detail: { preview: now } });
|
|
713
|
+
}
|
|
517
714
|
if (!ids.length)
|
|
518
715
|
return [];
|
|
519
|
-
if (!op.confirm) {
|
|
520
|
-
// превью: то же замыкание, что у триггера удаления (ref_action), база не меняется
|
|
521
|
-
const res = await query(w, `select ${rowJson('e')} as row, p.action, p.depth from ${S}.delete_preview($1::uuid[]) p `
|
|
522
|
-
+ `join ${S}.entity e on e.id = p.id order by p.depth nulls last, p.action, e.id`, [ids]);
|
|
523
|
-
return res.map((r) => {
|
|
524
|
-
const row = toRow(r.row);
|
|
525
|
-
row.$action = r.action;
|
|
526
|
-
if (r.depth !== null)
|
|
527
|
-
row.$depth = r.depth;
|
|
528
|
-
return row;
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
716
|
// результат — надгробия этой транзакции после метки: delete … returning не видит каскад триггера
|
|
532
717
|
const [{ mark }] = await query(w, `select coalesce(max(seq), 0)::text as mark from ${S}.log where tx = pg_current_xact_id()`, []);
|
|
533
718
|
const gone = await query(w, `delete from ${S}.entity e where e.id = any ($1::uuid[]) returning ${rowJson('e')} as row`, [ids]);
|
|
@@ -550,99 +735,155 @@ async function deleteOp(w, steps, opStep) {
|
|
|
550
735
|
}
|
|
551
736
|
return out;
|
|
552
737
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const ids = await targetIds(w, steps, opStep);
|
|
557
|
-
if (!ids.length)
|
|
738
|
+
/** Причины отказа для строк шага restore (живых или надгробий не той версии): одним запросом к журналу. */
|
|
739
|
+
async function refusals(w, rows, want) {
|
|
740
|
+
if (!rows.length)
|
|
558
741
|
return [];
|
|
559
|
-
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), `
|
|
560
|
-
+ `tags = (select array_agg(distinct t order by t) from unnest(e.tags || array['anonymized']) t) `
|
|
561
|
-
+ `where e.id = any ($1::uuid[]) returning ${rowJson('new')} as row`, [ids, op.fields]);
|
|
562
|
-
if (res.length < ids.length) {
|
|
563
|
-
const got = new Set(res.map((r) => r.row.id));
|
|
564
|
-
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
565
|
-
}
|
|
566
|
-
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
567
|
-
}
|
|
568
|
-
async function reclassOp(w, steps, opStep) {
|
|
569
|
-
const op = opStep.op;
|
|
570
742
|
const S = quoteId(w.schema);
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const dead = rows.filter((r) => r.$deleted);
|
|
585
|
-
if (!dead.length && rows.length)
|
|
586
|
-
fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
|
|
587
|
-
// без явного порядка — по id: restore и purge пишут журнал по очереди целей
|
|
588
|
-
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
743
|
+
const exp = rows.map((r) => want?.get(r.id.toLowerCase()) ?? null);
|
|
744
|
+
const log = await query(w, `select x.id::text as id, `
|
|
745
|
+
+ `(select l.op from ${S}.log l where l.id = x.id and l.rev > x.exp order by l.rev limit 1) as after, `
|
|
746
|
+
+ `(select l.op from ${S}.log l where l.id = x.id and l.rev > (select max(t.rev) from ${S}.log t where t.id = x.id and t.op = 'delete') `
|
|
747
|
+
+ `order by l.rev limit 1) as after_last from unnest($1::uuid[], $2::int[]) as x(id, exp)`, [rows.map((r) => r.id), exp]);
|
|
748
|
+
const by = new Map(log.map((r) => [r.id.toLowerCase(), r]));
|
|
749
|
+
return rows.map((r, i) => {
|
|
750
|
+
const l = by.get(r.id.toLowerCase());
|
|
751
|
+
const live = r.$deleted !== true;
|
|
752
|
+
const last = l?.after_last ?? null;
|
|
753
|
+
const why = live && last === 'restore' ? 'restored' : live && last === 'create' ? 'reissued' : 'changed';
|
|
754
|
+
return { id: r.id, rev: r.rev, expected: exp[i], after: l?.after ?? null, deleted: !live, live, why };
|
|
755
|
+
});
|
|
589
756
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
757
|
+
/**
|
|
758
|
+
* Отказ restore по смыслу (второй раунд 2.1, A2): есть `changed` — restore_stale (conflict), иначе есть `reissued` —
|
|
759
|
+
* restore_id_taken, иначе restore_already (оба — not_deleted). detail.ids — цели этого ключа; с expect (и всегда у
|
|
760
|
+
* restore_stale) detail.stale — все цели, которые не восстановить, с причиной.
|
|
761
|
+
*/
|
|
762
|
+
function restoreRefusal(verb, list, withExpect, cause) {
|
|
763
|
+
const of = (why) => list.filter((x) => x.why === why).map((x) => x.id);
|
|
764
|
+
const extra = (detail) => ({ detail, ...(cause !== undefined ? { cause } : {}) });
|
|
765
|
+
const stale = withExpect ? { stale: list } : {};
|
|
766
|
+
const changed = of('changed');
|
|
767
|
+
if (changed.length)
|
|
768
|
+
return lerr('write.restore_stale', { ids: changed.join(', ') }, extra({ ids: changed, stale: list }));
|
|
769
|
+
const taken = of('reissued');
|
|
770
|
+
if (taken.length)
|
|
771
|
+
return lerr('write.restore_id_taken', { verb, ids: taken.join(', ') }, extra({ ids: taken, ...stale }));
|
|
772
|
+
const done = of('restored');
|
|
773
|
+
return lerr('write.restore_already', { verb, ids: done.join(', ') }, extra({ ids: done, ...stale }));
|
|
600
774
|
}
|
|
775
|
+
/** Строка ответа restore({ expect, partial: true }) для цели, которую не восстановить (второй раунд 2.1, A3). */
|
|
776
|
+
const skipRow = (r, why) => ({ ...r, $action: 'skip', $why: why });
|
|
777
|
+
const byIdOrder = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
601
778
|
/**
|
|
602
|
-
*
|
|
603
|
-
*
|
|
779
|
+
* Удалённые объекты шага (restore). Шаг цели читается с надгробиями; у шагов до него withDeleted() — свой (план 2.1, A9).
|
|
780
|
+
* Без expect: восстанавливаются удалённые цели; если удалённых нет, а живые есть — отказ по смыслу (restoreRefusal: уже
|
|
781
|
+
* восстановлен, id занят новым объектом) или not_deleted (не удалялся). С expect — только цели из expect, и последняя
|
|
782
|
+
* версия каждой — то же надгробие; иначе отказ по смыслу и ничего не восстановлено, а с partial — строка-пропуск.
|
|
604
783
|
*/
|
|
605
|
-
async function
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
784
|
+
async function deletedTargets(w, steps, opStep, verb, expect, partial) {
|
|
785
|
+
const path = steps.map(bare);
|
|
786
|
+
path[path.length - 1] = { ...path[path.length - 1], withDeleted: true };
|
|
787
|
+
const rows = await readRows(w, path, opStep.opMods ?? {});
|
|
788
|
+
// без явного порядка — по id: restore пишет журнал по очереди целей
|
|
789
|
+
const ordered = (xs) => (opStep.opMods?.order ? xs : [...xs].sort(byIdOrder));
|
|
790
|
+
if (expect !== undefined) {
|
|
791
|
+
const want = new Map(expect.map((x) => [x.id.toLowerCase(), x.rev]));
|
|
792
|
+
const mine = rows.filter((r) => want.has(r.id.toLowerCase()));
|
|
793
|
+
const good = (r) => r.$deleted === true && r.rev === want.get(r.id.toLowerCase());
|
|
794
|
+
const bad = await refusals(w, mine.filter((r) => !good(r)), want);
|
|
795
|
+
if (bad.length && !partial)
|
|
796
|
+
throw restoreRefusal(verb, bad, true);
|
|
797
|
+
const why = new Map(bad.map((x) => [x.id, x.why]));
|
|
798
|
+
const all = ordered(mine);
|
|
799
|
+
return { dead: all.filter(good), skip: all.filter((r) => !good(r)).map((r) => skipRow(r, why.get(r.id) ?? 'changed')), order: all.map((r) => r.id) };
|
|
613
800
|
}
|
|
614
|
-
|
|
801
|
+
const dead = rows.filter((r) => r.$deleted);
|
|
802
|
+
if (!dead.length && rows.length) {
|
|
803
|
+
const live = await refusals(w, rows, null);
|
|
804
|
+
if (live.some((x) => x.why !== 'changed'))
|
|
805
|
+
throw restoreRefusal(verb, live.filter((x) => x.why !== 'changed'), false);
|
|
806
|
+
throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
|
|
807
|
+
}
|
|
808
|
+
const all = ordered(dead);
|
|
809
|
+
return { dead: all, skip: [], order: all.map((r) => r.id) };
|
|
615
810
|
}
|
|
616
|
-
/**
|
|
617
|
-
|
|
618
|
-
const
|
|
811
|
+
/** Отказ функции базы restore() по смыслу (уже восстановлен, id занят, версия не та) — причина и подробности; иначе null. */
|
|
812
|
+
function dbRefusal(e) {
|
|
813
|
+
const le = fromDbError(e);
|
|
814
|
+
if (!(le instanceof LetopisError))
|
|
815
|
+
return null;
|
|
816
|
+
const why = le.key === 'sql-time.restore_already' ? 'restored' : le.key === 'sql-time.restore_id_taken' ? 'reissued'
|
|
817
|
+
: le.key === 'sql-time.restore_stale' ? 'changed' : undefined;
|
|
818
|
+
if (!why)
|
|
819
|
+
return null;
|
|
820
|
+
const d = (le.detail ?? {});
|
|
821
|
+
const p = le.params ?? {};
|
|
822
|
+
return {
|
|
823
|
+
id: String(d.id ?? p.id ?? ''), rev: Number(d.rev ?? p.rev ?? 0), expected: d.expected ?? (typeof p.expected === 'number' ? p.expected : null),
|
|
824
|
+
after: d.after ?? null, deleted: d.deleted ?? false, live: d.live ?? why !== 'changed', why,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. С expect
|
|
829
|
+
* база ещё раз сверяет номер последней версии под блокировкой записи журнала (restore(id, rev), план 2.1, A1): гонку
|
|
830
|
+
* между чтением и восстановлением ловит она, и её отказ — по смыслу, как у проверки до записи (второй раунд 2.1, A2).
|
|
831
|
+
* partial (A3): цели, которые не восстановить, — строки-пропуски { …строка, $action: 'skip', $why }, остальные
|
|
832
|
+
* восстанавливаются; каждая цель — под своей точкой сохранения, проигранная гонка тоже становится пропуском.
|
|
833
|
+
*/
|
|
834
|
+
async function restoreOp(w, steps, opStep) {
|
|
619
835
|
const S = quoteId(w.schema);
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
836
|
+
const { expect, partial } = opStep.op;
|
|
837
|
+
const { dead, skip, order } = await deletedTargets(w, steps, opStep, 'restore', expect, partial);
|
|
838
|
+
if (!dead.length)
|
|
839
|
+
return inTargetOrder(order, skip);
|
|
840
|
+
let ids = dead.map((r) => r.id);
|
|
841
|
+
if (expect === undefined) {
|
|
842
|
+
// прежний вызов restore(id): работает и со схемой прежней ревизии
|
|
843
|
+
try {
|
|
844
|
+
await query(w, `select ${S}.restore(x) from unnest($1::uuid[]) with ordinality as u(x, i) order by u.i`, [ids]);
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
const r = dbRefusal(e);
|
|
848
|
+
if (r && r.why !== 'changed')
|
|
849
|
+
throw restoreRefusal('restore', [r], false, e);
|
|
850
|
+
throw e;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
else if (!partial) {
|
|
634
854
|
try {
|
|
635
|
-
|
|
636
|
-
moved.push(nid);
|
|
855
|
+
await query(w, `select ${S}.restore(u.x, u.r) from unnest($1::uuid[], $2::int[]) with ordinality as u(x, r, i) order by u.i`, [ids, dead.map((r) => r.rev)]);
|
|
637
856
|
}
|
|
638
857
|
catch (e) {
|
|
639
|
-
|
|
640
|
-
|
|
858
|
+
const r = dbRefusal(e);
|
|
859
|
+
if (r)
|
|
860
|
+
throw restoreRefusal('restore', [r], true, e);
|
|
641
861
|
throw e;
|
|
642
862
|
}
|
|
643
863
|
}
|
|
644
|
-
|
|
645
|
-
|
|
864
|
+
else {
|
|
865
|
+
const lost = new Set();
|
|
866
|
+
for (const r of dead) {
|
|
867
|
+
await w.q.unsafe('savepoint letopis_restore');
|
|
868
|
+
try {
|
|
869
|
+
await query(w, `select ${S}.restore($1::uuid, $2::int)`, [r.id, r.rev]);
|
|
870
|
+
await w.q.unsafe('release savepoint letopis_restore');
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
await w.q.unsafe('rollback to savepoint letopis_restore');
|
|
874
|
+
const why = dbRefusal(e)?.why;
|
|
875
|
+
if (!why)
|
|
876
|
+
throw e;
|
|
877
|
+
lost.add(r.id);
|
|
878
|
+
skip.push(skipRow(r, why));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
ids = ids.filter((id) => !lost.has(id));
|
|
882
|
+
}
|
|
883
|
+
const res = ids.length
|
|
884
|
+
? await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids])
|
|
885
|
+
: [];
|
|
886
|
+
return inTargetOrder(order, [...res.map((r) => toRow(r.row)), ...skip]);
|
|
646
887
|
}
|
|
647
888
|
async function opCall(w, steps, opStep) {
|
|
648
889
|
await useReason(w, reasonFor(w, opStep));
|
|
@@ -651,11 +892,7 @@ async function opCall(w, steps, opStep) {
|
|
|
651
892
|
case 'upsert': return createOp(w, steps, opStep, true);
|
|
652
893
|
case 'update': return updateOp(w, steps, opStep);
|
|
653
894
|
case 'delete': return deleteOp(w, steps, opStep);
|
|
654
|
-
case 'anonymize': return anonymizeOp(w, steps, opStep);
|
|
655
|
-
case 'reclass': return reclassOp(w, steps, opStep);
|
|
656
895
|
case 'restore': return restoreOp(w, steps, opStep);
|
|
657
|
-
case 'purge': return purgeOp(w, steps, opStep);
|
|
658
|
-
case 'rekey': return rekeyOp(w, steps, opStep);
|
|
659
896
|
}
|
|
660
897
|
}
|
|
661
898
|
/** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
|
|
@@ -712,18 +949,18 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
712
949
|
else {
|
|
713
950
|
last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
|
|
714
951
|
}
|
|
715
|
-
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после
|
|
716
|
-
//
|
|
952
|
+
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире); после restore — от
|
|
953
|
+
// восстановленных, без строк-пропусков partial
|
|
717
954
|
start = kind === 'delete' && opStep.op.confirm
|
|
718
955
|
? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
|
|
719
|
-
: kind === '
|
|
956
|
+
: kind === 'restore' ? last.filter((r) => r.$action !== 'skip') : last;
|
|
720
957
|
prevOp = opStep;
|
|
721
958
|
}
|
|
722
959
|
// причина глагола не переходит на следующие операторы транзакции
|
|
723
960
|
await useReason(w, w.reason ?? '');
|
|
724
961
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
725
962
|
const key = opStep.aliasKey ?? opStep.name;
|
|
726
|
-
if (tail.length || mode === 'versions' || mode === 'agg'
|
|
963
|
+
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
727
964
|
if (!start.length)
|
|
728
965
|
return { kind: 'read', q: null, res: [] };
|
|
729
966
|
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
@@ -793,13 +1030,11 @@ export async function execBatch(w, plans) {
|
|
|
793
1030
|
}
|
|
794
1031
|
await useReason(w, why);
|
|
795
1032
|
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
796
|
-
// агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
|
|
797
|
-
const agg = aggregateFields(head.cls);
|
|
798
1033
|
const conflict = verb === 'upsert'
|
|
799
|
-
?
|
|
1034
|
+
? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
|
|
800
1035
|
: 'on conflict (id) do nothing';
|
|
801
1036
|
const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
|
|
802
|
-
+ `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
|
|
1037
|
+
+ `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]);
|
|
803
1038
|
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
804
1039
|
for (const g of group) {
|
|
805
1040
|
const r = byId.get(g.id);
|