letopis 1.4.2 → 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 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- 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 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- 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 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- 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 +20 -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 +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- 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 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- 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 -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,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,67 +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
|
-
// поля нет в классе — так и сказано, а не «поле 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}`);
|
|
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 });
|
|
194
194
|
}
|
|
195
195
|
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
196
|
case 'delete':
|
|
203
|
-
|
|
204
|
-
|
|
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 });
|
|
205
200
|
break;
|
|
206
|
-
case '
|
|
207
|
-
if (!s.cls.key.length)
|
|
208
|
-
fail('no_key', `rekey(): у класса ${s.cls.name} нет ключа — id выдаёт база, менять нечего`);
|
|
209
|
-
checkFullData(s.cls, op.data, 'rekey', false);
|
|
201
|
+
case 'restore':
|
|
210
202
|
break;
|
|
211
203
|
}
|
|
212
204
|
for (const sl of s.slots ?? []) {
|
|
213
205
|
if (op.kind !== 'create' && op.kind !== 'update' && op.kind !== 'upsert')
|
|
214
|
-
|
|
206
|
+
throw lerr('write.slot_bad_verb', { role: sl.role, verb });
|
|
215
207
|
}
|
|
208
|
+
precheckStep(reg, s, s.opMods?.order);
|
|
209
|
+
if (tail && (order !== undefined || agg !== undefined))
|
|
210
|
+
precheckStep(reg, s, order, agg);
|
|
216
211
|
prevOp = op;
|
|
217
212
|
}
|
|
218
213
|
}
|
|
@@ -228,7 +223,7 @@ const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
|
228
223
|
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
229
224
|
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
230
225
|
+ `'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
|
|
226
|
+
+ `'op', ${a}.op, 'reason', ${a}.reason)`;
|
|
232
227
|
async function query(w, text, params) {
|
|
233
228
|
return (await w.q.unsafe(text, params, { prepare: true }));
|
|
234
229
|
}
|
|
@@ -254,16 +249,18 @@ export function slotRole(reg, owner, name) {
|
|
|
254
249
|
return name;
|
|
255
250
|
const c = reg.find(name);
|
|
256
251
|
if (!c)
|
|
257
|
-
|
|
252
|
+
throw lerr('write.slot_unknown', { name });
|
|
258
253
|
const roles = rolesAccepting(reg, owner, c.name);
|
|
259
254
|
if (roles.length === 1)
|
|
260
255
|
return roles[0];
|
|
261
256
|
if (!roles.length)
|
|
262
|
-
|
|
263
|
-
|
|
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(', ') });
|
|
264
259
|
}
|
|
265
260
|
/** Неоднозначная роль конца из пути. */
|
|
266
|
-
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
|
+
};
|
|
267
264
|
/**
|
|
268
265
|
* Роль из нескольких кандидатов: роли, заданные слотами, не участвуют; из оставшихся — роль с именем
|
|
269
266
|
* класса шага (duty.Member для шага Member, а Backup — слотом). Иначе — неоднозначно.
|
|
@@ -317,7 +314,7 @@ async function pathEnds(w, steps, taken) {
|
|
|
317
314
|
else {
|
|
318
315
|
const rows = await readRows(w, steps.slice(0, k + 1).map(bare), { limit: 2 });
|
|
319
316
|
if (rows.length !== 1)
|
|
320
|
-
|
|
317
|
+
throw lerr('write.end_not_single', { step: s.name, cls: tcls.name, count: rows.length });
|
|
321
318
|
id = rows[0].id;
|
|
322
319
|
if (!roles) {
|
|
323
320
|
roles = pickRole(s, tcls, rolesAccepting(w.reg, tcls, rows[0].class), taken, rows[0].class);
|
|
@@ -368,7 +365,7 @@ async function resolveSlots(w, cls, slots) {
|
|
|
368
365
|
const end = cls.ends[sl.role];
|
|
369
366
|
const ids = sl.op === 'unset' ? [] : await slotIds(w, sl.value);
|
|
370
367
|
if (sl.op === 'set' && sl.value !== null && !end.many && ids.length !== 1) {
|
|
371
|
-
|
|
368
|
+
throw lerr('write.slot_single_many', { role: sl.role, count: ids.length });
|
|
372
369
|
}
|
|
373
370
|
out.push({ role: sl.role, op: sl.op, value: sl.op === 'unset' || sl.value === null ? null : end.many ? ids : ids[0] });
|
|
374
371
|
}
|
|
@@ -405,7 +402,8 @@ function existsError(w, cls, data, links, given, cause) {
|
|
|
405
402
|
id = undefined;
|
|
406
403
|
}
|
|
407
404
|
}
|
|
408
|
-
|
|
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 });
|
|
409
407
|
}
|
|
410
408
|
const isPkConflict = (e) => {
|
|
411
409
|
const err = e;
|
|
@@ -419,16 +417,11 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
419
417
|
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
420
418
|
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
421
419
|
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
|
-
: '';
|
|
420
|
+
const conflict = upsert ? ' on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags' : '';
|
|
428
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)`
|
|
429
422
|
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
430
423
|
try {
|
|
431
|
-
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]);
|
|
432
425
|
return res.map((r) => {
|
|
433
426
|
const row = toRow(r.row);
|
|
434
427
|
if (upsert)
|
|
@@ -449,12 +442,17 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
449
442
|
async function explainMissing(w, missing, rev) {
|
|
450
443
|
const S = quoteId(w.schema);
|
|
451
444
|
const seen = await query(w, `select id, rev from ${S}.entity where id = any ($1::uuid[])`, [missing]);
|
|
452
|
-
if (!seen.length)
|
|
453
|
-
|
|
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
|
+
}
|
|
454
450
|
if (rev !== undefined && seen.some((r) => r.rev !== rev)) {
|
|
455
|
-
|
|
451
|
+
const detail = { id: seen[0].id, rev: seen[0].rev, expected: rev };
|
|
452
|
+
throw lerr('write.rev_conflict', detail, { detail });
|
|
456
453
|
}
|
|
457
|
-
|
|
454
|
+
const ids = seen.map((r) => r.id);
|
|
455
|
+
throw lerr('write.rows_denied', { ids: ids.join(', ') }, { detail: { ids } });
|
|
458
456
|
}
|
|
459
457
|
/**
|
|
460
458
|
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
@@ -464,14 +462,14 @@ function inTargetOrder(ids, rows) {
|
|
|
464
462
|
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
465
463
|
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
466
464
|
}
|
|
467
|
-
/** Цели update
|
|
465
|
+
/** Цели update и delete: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
468
466
|
async function targetIds(w, steps, opStep, dataId) {
|
|
469
467
|
const path = steps.map(bare);
|
|
470
468
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
471
469
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
472
470
|
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
473
|
-
// без явного порядка — по id: глаголы по списку целей
|
|
474
|
-
//
|
|
471
|
+
// без явного порядка — по id: глаголы по списку целей пишут журнал по очереди, и порядок записей не должен
|
|
472
|
+
// зависеть от плана чтения
|
|
475
473
|
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
476
474
|
}
|
|
477
475
|
async function updateOp(w, steps, opStep) {
|
|
@@ -483,9 +481,9 @@ async function updateOp(w, steps, opStep) {
|
|
|
483
481
|
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
484
482
|
if (op.rev !== undefined) {
|
|
485
483
|
if (ids.length > 1)
|
|
486
|
-
|
|
484
|
+
throw lerr('write.rev_many_targets', { step: opStep.name, count: ids.length });
|
|
487
485
|
if (!ids.length)
|
|
488
|
-
|
|
486
|
+
throw lerr('write.rev_no_target', { step: opStep.name });
|
|
489
487
|
}
|
|
490
488
|
if (!ids.length)
|
|
491
489
|
return [];
|
|
@@ -550,41 +548,13 @@ async function deleteOp(w, steps, opStep) {
|
|
|
550
548
|
}
|
|
551
549
|
return out;
|
|
552
550
|
}
|
|
553
|
-
|
|
554
|
-
const op = opStep.op;
|
|
555
|
-
const S = quoteId(w.schema);
|
|
556
|
-
const ids = await targetIds(w, steps, opStep);
|
|
557
|
-
if (!ids.length)
|
|
558
|
-
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
|
-
const S = quoteId(w.schema);
|
|
571
|
-
const ids = await targetIds(w, steps, opStep);
|
|
572
|
-
if (!ids.length)
|
|
573
|
-
return [];
|
|
574
|
-
const cls = w.reg.resolve(op.cls).name;
|
|
575
|
-
// у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
|
|
576
|
-
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]);
|
|
577
|
-
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)]);
|
|
578
|
-
// строки ответа — в порядке целей (moved — по порядку целей, план 2.0, 7.2 №2)
|
|
579
|
-
return inTargetOrder(moved.map((m) => m.id), res.map((r) => toRow(r.row)));
|
|
580
|
-
}
|
|
581
|
-
/** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
|
|
551
|
+
/** Удалённые объекты шага (restore): живые цели без удалённых — not_deleted. */
|
|
582
552
|
async function deletedTargets(w, steps, opStep, verb) {
|
|
583
553
|
const rows = await readRows(w, steps.map(bare), { ...(opStep.opMods ?? {}), withDeleted: true });
|
|
584
554
|
const dead = rows.filter((r) => r.$deleted);
|
|
585
555
|
if (!dead.length && rows.length)
|
|
586
|
-
|
|
587
|
-
// без явного порядка — по id: restore
|
|
556
|
+
throw lerr('write.not_deleted', { verb }, { detail: { ids: rows.map((r) => r.id) } });
|
|
557
|
+
// без явного порядка — по id: restore пишет журнал по очереди целей
|
|
588
558
|
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
589
559
|
}
|
|
590
560
|
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
@@ -598,52 +568,6 @@ async function restoreOp(w, steps, opStep) {
|
|
|
598
568
|
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
|
|
599
569
|
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
600
570
|
}
|
|
601
|
-
/**
|
|
602
|
-
* Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
|
|
603
|
-
* версий $versions), с ним — версии стёрты, на месте версии 1 — запись purge ($purged).
|
|
604
|
-
*/
|
|
605
|
-
async function purgeOp(w, steps, opStep) {
|
|
606
|
-
const op = opStep.op;
|
|
607
|
-
const S = quoteId(w.schema);
|
|
608
|
-
const dead = await deletedTargets(w, steps, opStep, 'purge');
|
|
609
|
-
const out = [];
|
|
610
|
-
for (const row of dead) {
|
|
611
|
-
const [{ r }] = await query(w, `select ${S}.purge($1::uuid, $2::boolean) as r`, [row.id, op.confirm]);
|
|
612
|
-
out.push({ ...row, $versions: r.versions, ...(r.purged ? { $purged: true } : {}) });
|
|
613
|
-
}
|
|
614
|
-
return out;
|
|
615
|
-
}
|
|
616
|
-
/** Новое значение ключа (этап 6, п. 7): строка с новым id, ссылки переведены, журнал связывает id. */
|
|
617
|
-
async function rekeyOp(w, steps, opStep) {
|
|
618
|
-
const op = opStep.op;
|
|
619
|
-
const S = quoteId(w.schema);
|
|
620
|
-
const ids = await targetIds(w, steps, opStep);
|
|
621
|
-
if (!ids.length)
|
|
622
|
-
return [];
|
|
623
|
-
// поля — в данные, роли концов — в концы
|
|
624
|
-
const data = {};
|
|
625
|
-
const links = {};
|
|
626
|
-
for (const [k, v] of Object.entries(op.data)) {
|
|
627
|
-
if (opStep.cls.ends[k])
|
|
628
|
-
links[k] = typeof v === 'object' && v !== null && !Array.isArray(v) ? v.id : v;
|
|
629
|
-
else
|
|
630
|
-
data[k] = v;
|
|
631
|
-
}
|
|
632
|
-
const moved = [];
|
|
633
|
-
for (const id of ids) {
|
|
634
|
-
try {
|
|
635
|
-
const [{ id: nid }] = await query(w, `select ${S}.rekey($1::uuid, $2::jsonb, $3::jsonb) as id`, [id, data, links]);
|
|
636
|
-
moved.push(nid);
|
|
637
|
-
}
|
|
638
|
-
catch (e) {
|
|
639
|
-
if (isPkConflict(e))
|
|
640
|
-
throw Object.assign(new LetopisError('exists', `rekey(): объект класса ${opStep.cls.name} с таким ключом уже существует`, { id }), { cause: e });
|
|
641
|
-
throw e;
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [moved]);
|
|
645
|
-
return inTargetOrder(moved, res.map((r) => toRow(r.row)));
|
|
646
|
-
}
|
|
647
571
|
async function opCall(w, steps, opStep) {
|
|
648
572
|
await useReason(w, reasonFor(w, opStep));
|
|
649
573
|
switch (opStep.op.kind) {
|
|
@@ -651,11 +575,7 @@ async function opCall(w, steps, opStep) {
|
|
|
651
575
|
case 'upsert': return createOp(w, steps, opStep, true);
|
|
652
576
|
case 'update': return updateOp(w, steps, opStep);
|
|
653
577
|
case 'delete': return deleteOp(w, steps, opStep);
|
|
654
|
-
case 'anonymize': return anonymizeOp(w, steps, opStep);
|
|
655
|
-
case 'reclass': return reclassOp(w, steps, opStep);
|
|
656
578
|
case 'restore': return restoreOp(w, steps, opStep);
|
|
657
|
-
case 'purge': return purgeOp(w, steps, opStep);
|
|
658
|
-
case 'rekey': return rekeyOp(w, steps, opStep);
|
|
659
579
|
}
|
|
660
580
|
}
|
|
661
581
|
/** Разрезать план: сегменты (…шаги + шаг с глаголом) и читающий хвост после последнего глагола. */
|
|
@@ -712,18 +632,17 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
712
632
|
else {
|
|
713
633
|
last = await opCall(w, [fromRows(prevOp, start.map((r) => r.id)), ...seg.steps], opStep);
|
|
714
634
|
}
|
|
715
|
-
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
|
|
716
|
-
// стирания истории продолжать не от чего
|
|
635
|
+
// после удаления продолжение — от удалённых строк класса цели (замыкание каскада шире)
|
|
717
636
|
start = kind === 'delete' && opStep.op.confirm
|
|
718
637
|
? last.filter((r) => stepClasses(w.reg, opStep).includes(r.class))
|
|
719
|
-
:
|
|
638
|
+
: last;
|
|
720
639
|
prevOp = opStep;
|
|
721
640
|
}
|
|
722
641
|
// причина глагола не переходит на следующие операторы транзакции
|
|
723
642
|
await useReason(w, w.reason ?? '');
|
|
724
643
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
725
644
|
const key = opStep.aliasKey ?? opStep.name;
|
|
726
|
-
if (tail.length || mode === 'versions' || mode === 'agg'
|
|
645
|
+
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
727
646
|
if (!start.length)
|
|
728
647
|
return { kind: 'read', q: null, res: [] };
|
|
729
648
|
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
@@ -793,13 +712,11 @@ export async function execBatch(w, plans) {
|
|
|
793
712
|
}
|
|
794
713
|
await useReason(w, why);
|
|
795
714
|
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
715
|
const conflict = verb === 'upsert'
|
|
799
|
-
?
|
|
716
|
+
? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
|
|
800
717
|
: 'on conflict (id) do nothing';
|
|
801
718
|
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
|
|
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]);
|
|
803
720
|
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
804
721
|
for (const g of group) {
|
|
805
722
|
const r = byId.get(g.id);
|
package/llms.txt
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
1
|
# letopis
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Хранилище версионированных объектов для Node.js на PostgreSQL 18. Каждое изменение — версия в журнале (история, чтение на момент, подписка по курсору). Целостность, изоляцию арендаторов и права доступа проверяет сама база (триггеры и политики RLS), поэтому они действуют и для сырого SQL. Данные читают и пишут цепочками вида `db.Shop(shop).Product().rows()`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Главное для кода на letopis:
|
|
6
6
|
|
|
7
|
-
- ESM
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
- Errors carry a lowercase `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, `removed` (0.21 API with its replacement), …
|
|
7
|
+
- Пакет ESM, Node 22+, PostgreSQL 18+ с pgcrypto, кодировка базы UTF8. Зависимость времени выполнения — `postgres`. Zod 4 — необязательная peer-зависимость, нужна только `letopis/model`; `@modelcontextprotocol/server` — только `letopis/mcp`.
|
|
8
|
+
- Схему ставит `up({ dsn, schema })` от администраторской роли; имя схемы — `v2.<имя>`; `up({ upgrade: true })` обновляет схему отставшей ревизии (миграции и повторный накат), `up({ schema, fresh: true })` пересоздаёт схему — данные теряются. Приложение подключается ролью LOGIN — членом `letopis_app`: `connect({ dsn, schema: 'v2.<имя>', token | apiKey })`. Роль в обход RLS отклоняется (`bypass_rls`), если не передать `allowBypassRls: true`.
|
|
9
|
+
- Классы — хабы (объекты) и связи (отношения с концами). Их описывают `hub()` и `link()` из `letopis/model` и пишут в базу `syncModels()` или `letopis sync`. У класса с ключом id детерминированный, UUID v5 (`db.idOf`); уникальность — по ключу.
|
|
10
|
+
- Глаголы возвращают цепочку; терминал (`rows`, `first`, `ids`, `count`, …) исполняет весь план одной транзакцией. `create` только создаёт (занятый ключ — `exists`), `update` сливает и никогда не создаёт, `upsert` заменяет объект целиком (только у классов с ключом), `delete()` — превью, `delete({ confirm: true })` удаляет.
|
|
11
|
+
- Строгий режим. `connect({ models })` или `connect<Models>()` с файлом `letopis types` дают строгие типы: шаг — только класс или алиас моделей и системных классов, переходы — по концам, слоты — роли класса, поля фильтров, `or()` и агрегатов — поля класса; имя, известное только во время выполнения, — `loose(db)[имя]()`. Во время выполнения: `update()` и `delete({ confirm: true })` без фильтра, id, `tags()`, `owner()` и `limit()` ни у одного шага — `invalid_query` (все строки — `{ all: true }`); неизвестное поле фильтра, `sort()` или агрегата — `invalid_query` с похожими именами (сверяется первый ключ пути), неизвестный класс или роль — `invalid_class` с похожими; неизвестная опция `connect()`, `up()`, `load()`, `watch()`, `createMcpServer()` — `invalid_data` с похожими именами; `after()` перед `count()` и агрегатами — `invalid_query`; хаб с именем метода цепочки, корня `db` или батча — `invalid_class`.
|
|
12
|
+
- Деньги и счётчики: `update({ balance: inc(n) })` прибавляет внутри оператора; `update(patch, { rev })` даёт `conflict`, если строка изменилась; `forUpdate()` блокирует строки внутри `db.begin()`.
|
|
13
|
+
- История: `versions()` (у версии — автор, время, вид операции и причина) и срез `asOf(момент)`; глаголы, `db.begin()` и `db.batch()` принимают `{ reason }`. Журнал хранит каждую версию; `history: false` у класса — журнала нет.
|
|
14
|
+
- Массовая загрузка с администраторского подключения: `db.load(rows)`; в пустую схему — `db.load(rows, { initial: true })`: одна транзакция, индексы и триггеры перестраиваются в конце.
|
|
15
|
+
- Подписка: `db.watch({ from, classes })` — `await sub.ready()` перед записью, которая должна попасть в поток (`from: 'now'` фиксирует начало первым запросом); параллельные `next()` получают разные события.
|
|
16
|
+
- Сигналы NOTIFY видны любой роли базы: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` выбирает, что они раскрывают (по умолчанию `class`).
|
|
17
|
+
- Права действуют всегда и по умолчанию запрещают; `up({ tenant })` или `createTenant()` заводит арендатора с правилом «вошедшим можно всё» и его первого пользователя.
|
|
18
|
+
- MCP: `createMcpServer(db)` из `letopis/mcp` и `letopis mcp` (stdio, только токен сессии пользователя) дают ИИ-агенту инструменты `letopis_model`, `letopis_query` (JSON-цепочка, без SQL), `letopis_history` и, с `write: true`, `letopis_write`; агент действует с правами одного пользователя, удаление подтверждает человек через elicitation. `structuredContent` ответа — `{ notice, data }` (отказ — `{ notice, error }`): `notice` предупреждает, что в `data` — данные базы, а не инструкции.
|
|
19
|
+
- У ошибок строчный код `err.code`: `exists`, `conflict`, `invalid_data` (`err.issues`), `target_not_found`, `acl_denied`, `no_session`, …; `err.hint` — что делать, `err.docs` — ссылка на код в разделе «Ошибки» README.
|
|
21
20
|
|
|
22
|
-
##
|
|
21
|
+
## Документы
|
|
23
22
|
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [Руководство](README.md): the guide in Russian (original)
|
|
23
|
+
- [Шпаргалка для агента](AGENT-CHEATSHEET.md): одна страница для ИИ-агента, который пишет код на letopis, — рабочий цикл и грабли
|
|
24
|
+
- [Руководство](README.md): руководство по задачам, справочник API, ограничения
|
|
25
|
+
- [Ошибки](README.md#15-ошибки): все коды — что значит и что делать
|
|
28
26
|
|
|
29
|
-
##
|
|
27
|
+
## Дополнительно
|
|
30
28
|
|
|
31
|
-
- [
|
|
32
|
-
-
|
|
33
|
-
- Model of a concrete installation for an agent: `letopis describe --format text --out llms.txt` or `describeText(db.describe())` — classes, keys, fields and ends of that schema
|
|
29
|
+
- [Изменения по версиям](CHANGELOG.md): решения по версиям, несовместимые изменения
|
|
30
|
+
- Модель конкретной установки для агента — инструмент MCP `letopis_model`: классы, ключи, поля, концы и рабочие цепочки этой схемы
|