letopis 1.4.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +5 -2
- package/AGENT-CHEATSHEET.md +5 -2
- package/CHANGELOG.md +140 -1
- package/README.en.md +66 -15
- package/README.md +62 -12
- package/dist/admin.js +11 -2
- package/dist/cache.js +3 -2
- package/dist/chain.d.ts +2 -1
- package/dist/chain.js +9 -3
- package/dist/cli.js +10 -2
- package/dist/errors.js +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.js +57 -9
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +5 -0
- package/dist/load.js +20 -7
- package/dist/mcp.js +4 -2
- package/dist/model.js +7 -1
- package/dist/project.d.ts +9 -0
- package/dist/project.js +45 -11
- package/dist/signals.d.ts +4 -4
- package/dist/signals.js +4 -4
- package/dist/sql.d.ts +6 -5
- package/dist/sql.js +100 -23
- package/dist/sync.js +11 -4
- package/dist/tx.d.ts +15 -0
- package/dist/tx.js +12 -3
- package/dist/types.d.ts +8 -1
- package/dist/up.js +5 -0
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +24 -7
- package/dist/write.js +23 -9
- package/package.json +8 -4
- package/sql/60-write.sql +9 -0
- package/sql/70-read.sql +13 -7
- package/sql/75-history.sql +106 -28
- package/sql/80-auth.sql +2 -1
- package/sql/88-derived.sql +2 -1
- package/sql/90-time.sql +106 -28
package/dist/watch.js
CHANGED
|
@@ -61,6 +61,14 @@ export class Subscription {
|
|
|
61
61
|
closed = false;
|
|
62
62
|
wake = null;
|
|
63
63
|
dirty = true;
|
|
64
|
+
/** Пришёл сигнал: пустой ответ — ещё не горизонт, нужны короткие повторы. Таймер их не запускает (план 2.0, 7.4 №1). */
|
|
65
|
+
burst = false;
|
|
66
|
+
/**
|
|
67
|
+
* from: 'start' — пока подписка не догнала журнал, свои чтения она шлёт без времени курсора: весь журнал, без
|
|
68
|
+
* cursor_expired на старых событиях (план 2.0, 7.4 №3). Курсоры событий и sub.cursor время хранят: продолжение с них
|
|
69
|
+
* после прореживания — cursor_expired, как обещано.
|
|
70
|
+
*/
|
|
71
|
+
catchingUp = false;
|
|
64
72
|
off = null;
|
|
65
73
|
timer = null;
|
|
66
74
|
failure = null;
|
|
@@ -76,8 +84,10 @@ export class Subscription {
|
|
|
76
84
|
this.batch = opts.batch ?? 500;
|
|
77
85
|
this.pollMs = opts.pollMs ?? 1000;
|
|
78
86
|
const from = opts.from ?? 'now';
|
|
79
|
-
if (from === 'start')
|
|
87
|
+
if (from === 'start') {
|
|
80
88
|
this.pos = { tx: '0', seq: '0', at: null };
|
|
89
|
+
this.catchingUp = true;
|
|
90
|
+
}
|
|
81
91
|
else if (from !== 'now')
|
|
82
92
|
this.pos = decode(from);
|
|
83
93
|
else {
|
|
@@ -85,11 +95,12 @@ export class Subscription {
|
|
|
85
95
|
this.start.catch(() => { });
|
|
86
96
|
}
|
|
87
97
|
if (ctx.onSignal) {
|
|
88
|
-
const watched = ctx.classes ? new Set(ctx.classes) : null;
|
|
89
98
|
this.off = ctx.onSignal((s) => {
|
|
90
|
-
|
|
99
|
+
const cls = ctx.classesOf?.() ?? ctx.classes;
|
|
100
|
+
if (!wakes(s, cls ? new Set(cls) : null, ctx.tenant?.() ?? null))
|
|
91
101
|
return;
|
|
92
102
|
this.dirty = true;
|
|
103
|
+
this.burst = true;
|
|
93
104
|
this.wake?.();
|
|
94
105
|
});
|
|
95
106
|
}
|
|
@@ -150,7 +161,7 @@ export class Subscription {
|
|
|
150
161
|
where (select count(*) from r) < $5 and (l.tx, l.seq) > ($1::text::xid8, $2::bigint)
|
|
151
162
|
and l.tx >= pg_snapshot_xmin(pg_current_snapshot()) and ($4::text[] is null or l.class = any ($4::text[]))
|
|
152
163
|
and ($6::text::pg_snapshot is null or not pg_visible_in_snapshot(l.tx, $6::text::pg_snapshot))
|
|
153
|
-
having (select count(*) from r) < $5`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
164
|
+
having (select count(*) from r) < $5`, [p.tx, p.seq, this.catchingUp ? null : p.at, this.ctx.classesOf?.() ?? this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
154
165
|
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
155
166
|
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
156
167
|
const read = both.filter((r) => r.src === 0).sort(order);
|
|
@@ -178,8 +189,10 @@ export class Subscription {
|
|
|
178
189
|
}
|
|
179
190
|
// догнала журнал — время курсора подписки (sub.cursor) обновляется и на пустых опросах: прореживать после него нечего,
|
|
180
191
|
// и подписка, к которой долго не было событий, не получает ложный cursor_expired. Курсоры событий хранят время события
|
|
181
|
-
if (caught && away.length < this.batch)
|
|
192
|
+
if (caught && away.length < this.batch) {
|
|
182
193
|
this.pos = { ...(this.pos ?? p), at: String(caught.row.at) };
|
|
194
|
+
this.catchingUp = false;
|
|
195
|
+
}
|
|
183
196
|
return rows.length;
|
|
184
197
|
}
|
|
185
198
|
/**
|
|
@@ -216,12 +229,15 @@ export class Subscription {
|
|
|
216
229
|
this.close();
|
|
217
230
|
throw e;
|
|
218
231
|
}
|
|
219
|
-
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной
|
|
220
|
-
|
|
232
|
+
// сигнал был, а событий ниже горизонта ещё нет: короткие повторы, затем — запасной опрос. Опрос по таймеру —
|
|
233
|
+
// один запрос: серия повторов на каждый тик давала около 4 запросов в секунду на молчащую подписку (план 2.0,
|
|
234
|
+
// 7.4 №1)
|
|
235
|
+
if (this.burst && ++empty < 40) {
|
|
221
236
|
await sleep(Math.min(25 * empty, 250));
|
|
222
237
|
continue;
|
|
223
238
|
}
|
|
224
239
|
this.dirty = false;
|
|
240
|
+
this.burst = false;
|
|
225
241
|
empty = 0;
|
|
226
242
|
}
|
|
227
243
|
await new Promise((r) => {
|
|
@@ -248,6 +264,7 @@ export class Subscription {
|
|
|
248
264
|
return;
|
|
249
265
|
this.closed = true;
|
|
250
266
|
this.off?.();
|
|
267
|
+
this.ctx.onClose?.(this);
|
|
251
268
|
if (this.timer)
|
|
252
269
|
clearInterval(this.timer);
|
|
253
270
|
this.wake?.();
|
package/dist/write.js
CHANGED
|
@@ -185,6 +185,9 @@ export function precheckPlan(reg, steps) {
|
|
|
185
185
|
if (!Array.isArray(op.fields) || !op.fields.length)
|
|
186
186
|
fail('invalid_query', 'anonymize(поля): нужен список полей');
|
|
187
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}`);
|
|
188
191
|
const t = reg.leafType(s.cls.name, [f]);
|
|
189
192
|
if (t !== 'text')
|
|
190
193
|
fail('invalid_query', `anonymize() затирает только строковые поля; ${f} — ${t}`);
|
|
@@ -343,9 +346,17 @@ async function slotIds(w, v) {
|
|
|
343
346
|
if (Array.isArray(v))
|
|
344
347
|
return v;
|
|
345
348
|
if ('plan' in v) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
+
let rows;
|
|
350
|
+
if (v.plan.some((s) => s.op)) {
|
|
351
|
+
// вложенный план — те же проверки до базы, что у плана терминала; с читающим хвостом после глагола execPlan
|
|
352
|
+
// отдаёт строки чтения (план 2.0, 7.2 №3: раньше — TypeError)
|
|
353
|
+
precheckPlan(w.reg, v.plan);
|
|
354
|
+
const r = await execPlan(w, v.plan, v.mods, 'rows');
|
|
355
|
+
rows = r.kind === 'rows' ? r.rows : r.res;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
rows = await readRows(w, v.plan, v.mods);
|
|
359
|
+
}
|
|
349
360
|
return rows.map((r) => r.id);
|
|
350
361
|
}
|
|
351
362
|
return [v.id];
|
|
@@ -564,7 +575,8 @@ async function reclassOp(w, steps, opStep) {
|
|
|
564
575
|
// у класса без ключа id сохраняется, у класса с ключом — новый id и перевод ссылок (reclass() в базе)
|
|
565
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]);
|
|
566
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)]);
|
|
567
|
-
|
|
578
|
+
// строки ответа — в порядке целей (moved — по порядку целей, план 2.0, 7.2 №2)
|
|
579
|
+
return inTargetOrder(moved.map((m) => m.id), res.map((r) => toRow(r.row)));
|
|
568
580
|
}
|
|
569
581
|
/** Удалённые объекты шага (restore, purge): живые цели без удалённых — not_deleted. */
|
|
570
582
|
async function deletedTargets(w, steps, opStep, verb) {
|
|
@@ -583,8 +595,8 @@ async function restoreOp(w, steps, opStep) {
|
|
|
583
595
|
return [];
|
|
584
596
|
const ids = dead.map((r) => r.id);
|
|
585
597
|
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));
|
|
598
|
+
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])`, [ids]);
|
|
599
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
588
600
|
}
|
|
589
601
|
/**
|
|
590
602
|
* Стирание истории удалённых (этап 6, п. 3): без { confirm: true } — превью (надгробия и число
|
|
@@ -629,8 +641,8 @@ async function rekeyOp(w, steps, opStep) {
|
|
|
629
641
|
throw e;
|
|
630
642
|
}
|
|
631
643
|
}
|
|
632
|
-
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[])
|
|
633
|
-
return res.map((r) => toRow(r.row));
|
|
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)));
|
|
634
646
|
}
|
|
635
647
|
async function opCall(w, steps, opStep) {
|
|
636
648
|
await useReason(w, reasonFor(w, opStep));
|
|
@@ -771,7 +783,9 @@ export async function execBatch(w, plans) {
|
|
|
771
783
|
&& reasonFor(w, plans[j][0]) === why) {
|
|
772
784
|
const s = plans[j][0];
|
|
773
785
|
const id = idFor(s);
|
|
774
|
-
|
|
786
|
+
// повтор id режет группу и у create: иначе вторая строка «находила» строку первой, и батч проходил молча
|
|
787
|
+
// (план 2.0, 7.2 №1); повтор уходит следующим оператором и получает exists
|
|
788
|
+
if (!id || seen.has(id))
|
|
775
789
|
break;
|
|
776
790
|
seen.add(id);
|
|
777
791
|
group.push({ at: j, s, id });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
|
@@ -68,16 +68,19 @@
|
|
|
68
68
|
"seed:booking": "tsx scripts/gen-seed-booking.mjs",
|
|
69
69
|
"test:import-salon": "tsx bench/import-salon.mjs",
|
|
70
70
|
"api:contract": "node scripts/gen-api-contract.mjs",
|
|
71
|
-
"prepublishOnly": "npm run typecheck && npm run check:docs && npm run build"
|
|
71
|
+
"prepublishOnly": "npm run typecheck && npm run check:docs && npm run build",
|
|
72
|
+
"check:semver": "node scripts/check-semver.mjs",
|
|
73
|
+
"prerelease": "node scripts/prerelease.mjs",
|
|
74
|
+
"soak": "tsx bench/soak.mjs"
|
|
72
75
|
},
|
|
73
76
|
"dependencies": {
|
|
74
77
|
"postgres": "^3.4.9"
|
|
75
78
|
},
|
|
76
79
|
"peerDependencies": {
|
|
77
|
-
"zod": "^4",
|
|
78
80
|
"@electric-sql/pglite": "^0.5.8",
|
|
79
81
|
"@electric-sql/pglite-socket": "^0.2.11",
|
|
80
|
-
"@modelcontextprotocol/server": "^2.4.0"
|
|
82
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
83
|
+
"zod": "^4"
|
|
81
84
|
},
|
|
82
85
|
"peerDependenciesMeta": {
|
|
83
86
|
"zod": {
|
|
@@ -102,6 +105,7 @@
|
|
|
102
105
|
"letopis-0": "npm:letopis@0.21.0",
|
|
103
106
|
"letopis-1": "npm:letopis@1.0.1",
|
|
104
107
|
"letopis-11": "npm:letopis@1.1.0",
|
|
108
|
+
"letopis-141": "npm:letopis@1.4.1",
|
|
105
109
|
"tsx": "^4.23.15",
|
|
106
110
|
"typescript": "~5.9.3",
|
|
107
111
|
"zod": "4.6.5"
|
package/sql/60-write.sql
CHANGED
|
@@ -472,8 +472,12 @@ as $$
|
|
|
472
472
|
declare
|
|
473
473
|
e @SCHEMA@.entity;
|
|
474
474
|
c record;
|
|
475
|
+
was text;
|
|
475
476
|
begin
|
|
476
477
|
select * into c from @SCHEMA@.ctx();
|
|
478
|
+
if p_class is not null then
|
|
479
|
+
select x.class into was from @SCHEMA@.entity x where x.id = p_id;
|
|
480
|
+
end if;
|
|
477
481
|
update @SCHEMA@.entity x
|
|
478
482
|
set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
|
|
479
483
|
rev = x.rev + 1, prev_hash = x.hash,
|
|
@@ -489,6 +493,11 @@ begin
|
|
|
489
493
|
select x.* into e from @SCHEMA@.entity x where x.id = p_id;
|
|
490
494
|
end if;
|
|
491
495
|
perform @SCHEMA@.log_version(e);
|
|
496
|
+
-- смена класса: сигнал и прежнего класса — его ответы кэша и подписки тоже устарели (log_version сигналит только новый;
|
|
497
|
+
-- путь через триггер шлёт оба; план 2.0, 7.4 №4)
|
|
498
|
+
if was is not null and was <> e.class then
|
|
499
|
+
perform @SCHEMA@.signal(array[was], array[e.tenant]);
|
|
500
|
+
end if;
|
|
492
501
|
return e;
|
|
493
502
|
end $$;
|
|
494
503
|
|
package/sql/70-read.sql
CHANGED
|
@@ -53,9 +53,11 @@ as $$
|
|
|
53
53
|
where r.class = any (p_classes) and r.at <= coalesce(p_at, 'infinity'::timestamptz)) c
|
|
54
54
|
cross join lateral (
|
|
55
55
|
select y.* from (select x.* from @SCHEMA@.log x where x.id = c.id offset 0) y
|
|
56
|
-
where y.
|
|
56
|
+
where y.at <= coalesce(p_at, 'infinity'::timestamptz)
|
|
57
57
|
order by y.rev desc limit 1) w
|
|
58
|
-
|
|
58
|
+
-- класс — у последней версии на момент, а не среди версий классов шага: после смены класса без смены id объект
|
|
59
|
+
-- не числится в прежнем классе (план 2.0, 7.1 №1)
|
|
60
|
+
where w.class = any (p_classes) and @SCHEMA@.link_ids(w.links) @> array[p_src]
|
|
59
61
|
$$;
|
|
60
62
|
|
|
61
63
|
-- id строк классов p_classes: по вложению в data (GIN entity_data), по целям концов (GIN entity_ends),
|
|
@@ -134,12 +136,16 @@ begin
|
|
|
134
136
|
else
|
|
135
137
|
-- арендатор id неизменен, записи purge и trim несут арендатора объекта: условие до distinct on не меняет, какая
|
|
136
138
|
-- версия id последняя (индекс log_class_tenant_at)
|
|
139
|
+
-- кандидаты — по классу (индекс log_class_tenant_at), последняя версия id на момент — любого класса, и класс
|
|
140
|
+
-- проверяется у неё: иначе после смены класса без смены id объект числился бы в прежнем классе (план 2.0, 7.1 №1)
|
|
137
141
|
return query select v.id, v.rev from (
|
|
138
|
-
select distinct
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
select distinct l.id from @SCHEMA@.log l
|
|
143
|
+
where l.class = any (p_classes) and l.tenant = any (v_tenants) and l.at <= coalesce(p_at, 'infinity'::timestamptz)) c
|
|
144
|
+
cross join lateral (
|
|
145
|
+
select y.id, y.rev, y.op, y.class, y.tenant, y.owner, y.tags, y.data, y.links
|
|
146
|
+
from @SCHEMA@.log y where y.id = c.id and y.at <= coalesce(p_at, 'infinity'::timestamptz)
|
|
147
|
+
order by y.rev desc limit 1) v
|
|
148
|
+
where v.class = any (p_classes) and (v.op = 'delete') = p_dead and v.op not in ('purge', 'trim', 'reset')
|
|
143
149
|
and (p_data is null or v.data @> p_data) and (p_ends is null or @SCHEMA@.link_ids(v.links) @> p_ends)
|
|
144
150
|
and @SCHEMA@.row_visible(v.tenant, v.class, v.id, v.owner, v.tags, v.data, v.links, c_tenant, c_account, c_plan);
|
|
145
151
|
-- свои членства в других арендаторах: версии, ссылавшиеся на аккаунт (GIN log_ends)
|
package/sql/75-history.sql
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
-- change_paths — пути для колонки changed (пишут триггер правки и log_version);
|
|
12
12
|
-- row_leaves — листья снимка строки (то, что объясняет blame);
|
|
13
13
|
-- version_paths — пути из changed тех версий, у которых предыдущая версия видна вызывающему;
|
|
14
|
+
-- history_segments — отрезки истории объекта по переносам id (follow и blame);
|
|
14
15
|
-- diff_versions — что изменилось между двумя версиями или моментами (jsonb в виде ответа diff());
|
|
15
16
|
-- blame — какая версия, кто, когда и почему последней изменила каждый лист (ответ blame()).
|
|
16
17
|
|
|
@@ -154,21 +155,79 @@ begin
|
|
|
154
155
|
if c_account is null then
|
|
155
156
|
return;
|
|
156
157
|
end if;
|
|
158
|
+
-- обе версии — поиском по (id, rev) на каждую пару: lateral с limit 1 не даёт планировщику соединить журнал хэшем.
|
|
159
|
+
-- Свой план вызова (первые пять на соединении) по фильтру видимости оценивал версии n в 2 строки и искал
|
|
160
|
+
-- предыдущую версию по одному id, проверяя номер фильтром, — квадрат от числа версий: blame по 1000 версиям
|
|
161
|
+
-- ~250 мс, по 10 000 — секунды (план 2.0, попутно с 7.1 №2)
|
|
157
162
|
return query
|
|
158
163
|
select n.id, n.rev, n.changed
|
|
159
164
|
from unnest(p_ids, p_revs) q(id, rev)
|
|
160
|
-
join
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
cross join lateral (select x.id, x.rev, x.changed from @SCHEMA@.log x
|
|
166
|
+
where x.id = q.id and x.rev = q.rev and x.changed is not null and x.op not in ('purge', 'trim', 'reset')
|
|
167
|
+
and @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c_tenant, c_account, c_plan)
|
|
168
|
+
limit 1) n
|
|
169
|
+
cross join lateral (select 1 from @SCHEMA@.log o
|
|
170
|
+
where o.id = n.id and o.rev = n.rev - 1 and o.op not in ('purge', 'trim', 'reset')
|
|
171
|
+
and @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c_tenant, c_account, c_plan)
|
|
172
|
+
limit 1) o;
|
|
173
|
+
end $$;
|
|
174
|
+
|
|
175
|
+
-- Отрезки истории объекта (план 2.0, 7.1 №2): версии id с номерами (lo, hi], hi null — до последней. Границы
|
|
176
|
+
-- отрезков на id — переносы: надгробие переноса (delete с moved) кончает отрезок, первая версия на новом id (reclass,
|
|
177
|
+
-- rekey с moved) начинает. Объект, заведённый на прежнем id после переноса, — другой отрезок: follow и blame его не
|
|
178
|
+
-- берут (прежде брали все версии всех связанных id). Начало — отрезок с версией p_rev объекта p_id (n = 0), дальше по
|
|
179
|
+
-- переносам назад (n < 0) и, если не p_back, вперёд (n > 0). Перенос пишется парой строк с moved друг на друга
|
|
180
|
+
-- (надгробие на старом id → новый id, первая версия на новом → старый): строки переноса id находит индекс log_moved
|
|
181
|
+
-- через парную строку, без прохода по версиям id; у объекта без переносов — один пустой поиск. Строка, чья пара стёрта
|
|
182
|
+
-- (история другой стороны удалена целиком или обрезана), границей не считается. Пара одного переноса — ближайшая по
|
|
183
|
+
-- seq (время у них может разойтись на микросекунды). Тот же обход строит versions({ follow }) в построителе
|
|
184
|
+
-- (lib/src/sql.ts:buildRead): меняются вместе. plpgsql, а не sql: план запроса кэшируется (тело функции на sql
|
|
185
|
+
-- планировалось на каждом вызове).
|
|
186
|
+
create or replace function @SCHEMA@.history_segments(p_id uuid, p_rev int, p_back boolean default false)
|
|
187
|
+
returns table (id uuid, lo int, hi int, n int)
|
|
188
|
+
language plpgsql stable
|
|
189
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
190
|
+
as $$
|
|
191
|
+
#variable_conflict use_column
|
|
192
|
+
begin
|
|
193
|
+
return query with recursive s(id, lo, hi, n) as (
|
|
194
|
+
select p_id, b.lo, b.hi, 0 from (
|
|
195
|
+
select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < p_rev), 0),
|
|
196
|
+
coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= p_rev), 0)) as lo,
|
|
197
|
+
least(min(x.rev) filter (where x.op = 'delete' and x.rev >= p_rev),
|
|
198
|
+
min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > p_rev)) as hi
|
|
199
|
+
from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = p_id where m.moved = p_id) b
|
|
200
|
+
union
|
|
201
|
+
select g.id, b.lo, b.hi, g.n
|
|
202
|
+
from s cross join lateral (
|
|
203
|
+
-- назад: первая версия отрезка — прибытие с прежнего id; прежний отрезок кончается надгробием переноса сюда
|
|
204
|
+
select a.moved as id, (select t.rev from @SCHEMA@.log t where t.id = a.moved and t.op = 'delete' and t.moved = s.id
|
|
205
|
+
order by abs(t.seq - a.seq) limit 1) as r, s.n - 1 as n
|
|
206
|
+
from @SCHEMA@.log a
|
|
207
|
+
where s.n <= 0 and a.id = s.id and a.rev = s.lo + 1 and a.moved is not null and a.op <> 'delete'
|
|
208
|
+
union all
|
|
209
|
+
-- вперёд: последняя версия отрезка — надгробие переноса; следующий отрезок начинается прибытием отсюда
|
|
210
|
+
select t.moved, (select a.rev from @SCHEMA@.log a where a.id = t.moved and a.op <> 'delete' and a.moved = s.id
|
|
211
|
+
order by abs(a.seq - t.seq) limit 1), s.n + 1
|
|
212
|
+
from @SCHEMA@.log t
|
|
213
|
+
where not p_back and s.n >= 0 and t.id = s.id and t.rev = s.hi and t.op = 'delete' and t.moved is not null
|
|
214
|
+
) g
|
|
215
|
+
cross join lateral (
|
|
216
|
+
select greatest(coalesce(max(x.rev) filter (where x.op = 'delete' and x.rev < g.r), 0),
|
|
217
|
+
coalesce(max(x.rev - 1) filter (where x.op <> 'delete' and x.rev <= g.r), 0)) as lo,
|
|
218
|
+
least(min(x.rev) filter (where x.op = 'delete' and x.rev >= g.r),
|
|
219
|
+
min(x.rev - 1) filter (where x.op <> 'delete' and x.rev > g.r)) as hi
|
|
220
|
+
from @SCHEMA@.log m join @SCHEMA@.log x on x.moved = m.id and x.id = g.id where m.moved = g.id) b
|
|
221
|
+
where g.r is not null and abs(g.n) <= 1000
|
|
222
|
+
)
|
|
223
|
+
select s.id, s.lo, s.hi, s.n from s;
|
|
166
224
|
end $$;
|
|
167
225
|
|
|
168
226
|
-- diff: что изменилось между двумя версиями объекта (ответ терминала diff()). «После» — номер версии
|
|
169
227
|
-- p_to, последняя версия на момент p_to_at или последняя вообще; «до» — номер p_from, последняя живая
|
|
170
228
|
-- версия на момент p_from_at (объекта не было — null, все пути added) или версия перед «после».
|
|
171
|
-
-- p_follow — через смену id (reclass, rekey) по колонке moved
|
|
229
|
+
-- p_follow — через смену id (reclass, rekey) по колонке moved: отрезки истории этого объекта (history_segments) от
|
|
230
|
+
-- версии p_to или последней версии p_id; надгробие переноса — не версия.
|
|
172
231
|
-- «После» — надгробие: to.deleted, изменения полей не перечисляются.
|
|
173
232
|
create or replace function @SCHEMA@.diff_versions(p_id uuid, p_from int default null, p_to int default null,
|
|
174
233
|
p_from_at timestamptz default null, p_to_at timestamptz default null, p_follow boolean default false)
|
|
@@ -181,21 +240,26 @@ set jit = off
|
|
|
181
240
|
as $$
|
|
182
241
|
declare
|
|
183
242
|
ids uuid[] := array[p_id];
|
|
243
|
+
los int[] := array[0];
|
|
244
|
+
his int[] := array[null::int];
|
|
245
|
+
multi boolean := false;
|
|
246
|
+
whole boolean := true;
|
|
184
247
|
t record;
|
|
185
248
|
f record;
|
|
186
249
|
has_f boolean;
|
|
187
250
|
begin
|
|
188
251
|
if p_follow then
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
union select l.id from @SCHEMA@.log l where l.moved = g.id) x)
|
|
195
|
-
select array_agg(g.id) into ids from g;
|
|
252
|
+
select array_agg(s.id), array_agg(s.lo), array_agg(s.hi) into ids, los, his
|
|
253
|
+
from (select distinct g.id, g.lo, g.hi from @SCHEMA@.history_segments(p_id, coalesce(p_to, (select max(l.rev) from @SCHEMA@.log l
|
|
254
|
+
where l.id = p_id and l.op not in ('purge', 'trim', 'reset')))) g) s;
|
|
255
|
+
multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
|
|
256
|
+
whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
|
|
196
257
|
end if;
|
|
197
258
|
select l.id, l.rev, l.at, l.seq, l.op, l.class, l.data, l.links, l.tags, l.owner into t from @SCHEMA@.log l
|
|
198
259
|
where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
|
|
260
|
+
and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
|
|
261
|
+
and l.rev <= coalesce(s.hi, 2147483647))
|
|
262
|
+
else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
|
|
199
263
|
and (p_to is null or (l.id = p_id and l.rev = p_to)) and (p_to_at is null or l.at <= p_to_at)
|
|
200
264
|
order by l.at desc, l.seq desc limit 1;
|
|
201
265
|
if t.id is null then
|
|
@@ -208,6 +272,9 @@ begin
|
|
|
208
272
|
elsif p_from_at is not null then
|
|
209
273
|
select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
|
|
210
274
|
where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
|
|
275
|
+
and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
|
|
276
|
+
and l.rev <= coalesce(s.hi, 2147483647))
|
|
277
|
+
else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
|
|
211
278
|
and l.at <= p_from_at
|
|
212
279
|
order by l.at desc, l.seq desc limit 1;
|
|
213
280
|
-- срез на момент: удалённого объекта тогда не было
|
|
@@ -215,6 +282,9 @@ begin
|
|
|
215
282
|
else
|
|
216
283
|
select l.id, l.rev, l.at, l.op, l.class, l.data, l.links, l.tags, l.owner into f from @SCHEMA@.log l
|
|
217
284
|
where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (p_follow and l.op = 'delete' and l.moved is not null)
|
|
285
|
+
and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
|
|
286
|
+
and l.rev <= coalesce(s.hi, 2147483647))
|
|
287
|
+
else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
|
|
218
288
|
and (l.at, l.seq) < (t.at, t.seq)
|
|
219
289
|
order by l.at desc, l.seq desc limit 1;
|
|
220
290
|
has_f := f.id is not null;
|
|
@@ -241,7 +311,9 @@ end $$;
|
|
|
241
311
|
-- blame: для каждого листа версии (последней на момент p_at или текущей) — версия, которая последней его
|
|
242
312
|
-- изменила, с автором, агентом, видом операции и причиной (ответ терминала blame()). Просматривается не
|
|
243
313
|
-- больше p_limit последних версий, назад через смену id (первая версия нового id указывает на прежний
|
|
244
|
-
-- колонкой moved)
|
|
314
|
+
-- колонкой moved) — по отрезкам истории этого объекта (history_segments): объект, что жил на этом id до переноса
|
|
315
|
+
-- или заведён на прежнем id после, — не его история; начало истории — первая версия самого раннего отрезка (у
|
|
316
|
+
-- объекта без переносов — версия 1). Пропуск номера версии (прореживание, обрезка, невидимая версия) — выпавшая версия могла
|
|
245
317
|
-- изменить лист и вернуть значение, поэтому лист точен (exact), только если от текущей версии до найденной
|
|
246
318
|
-- пропусков нет; лист, не менявшийся в просмотренной истории, принадлежит самой старой просмотренной версии —
|
|
247
319
|
-- exact, только если это версия 1 и пропусков нет вовсе. complete — история просмотрена целиком и без пропусков.
|
|
@@ -254,9 +326,12 @@ set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
|
254
326
|
set jit = off
|
|
255
327
|
as $$
|
|
256
328
|
declare
|
|
257
|
-
ids uuid[]
|
|
329
|
+
ids uuid[];
|
|
330
|
+
los int[];
|
|
331
|
+
his int[];
|
|
332
|
+
multi boolean;
|
|
333
|
+
whole boolean;
|
|
258
334
|
t record;
|
|
259
|
-
prev uuid;
|
|
260
335
|
lim int := greatest(coalesce(p_limit, 1000), 1);
|
|
261
336
|
res jsonb;
|
|
262
337
|
begin
|
|
@@ -264,12 +339,11 @@ begin
|
|
|
264
339
|
where l.id = p_id and l.op not in ('purge', 'trim', 'reset') and (p_at is null or l.at <= p_at)
|
|
265
340
|
order by l.rev desc limit 1;
|
|
266
341
|
if t.id is null then return null; end if;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
end loop;
|
|
342
|
+
-- отрезки от самого раннего: ids[1], los[1] — начало истории объекта
|
|
343
|
+
select array_agg(s.id order by s.n), array_agg(s.lo order by s.n), array_agg(s.hi order by s.n) into ids, los, his
|
|
344
|
+
from (select g.id, g.lo, g.hi, min(g.n) as n from @SCHEMA@.history_segments(p_id, t.rev, true) g group by 1, 2, 3) s;
|
|
345
|
+
multi := cardinality(ids) > (select count(distinct x) from unnest(ids) x);
|
|
346
|
+
whole := cardinality(ids) = 1 and los[1] = 0 and his[1] is null;
|
|
273
347
|
with v as (
|
|
274
348
|
-- соседняя более старая версия — окном (lead), а не соединением CTE с собой: соединение шло бы вложенным
|
|
275
349
|
-- циклом по квадрату числа версий
|
|
@@ -279,6 +353,9 @@ begin
|
|
|
279
353
|
lead(l.owner) over o as o_owner, lead(l.class) over o as o_class
|
|
280
354
|
from @SCHEMA@.log l
|
|
281
355
|
where l.id = any (ids) and l.op not in ('purge', 'trim', 'reset') and not (l.op = 'delete' and l.moved is not null)
|
|
356
|
+
and case when whole then true when multi then exists (select 1 from unnest(ids, los, his) s(id, lo, hi) where s.id = l.id and l.rev > s.lo
|
|
357
|
+
and l.rev <= coalesce(s.hi, 2147483647))
|
|
358
|
+
else l.rev > los[array_position(ids, l.id)] and l.rev <= coalesce(his[array_position(ids, l.id)], 2147483647) end
|
|
282
359
|
and (l.at, l.seq) <= (t.at, t.seq)
|
|
283
360
|
window o as (order by l.at desc, l.seq desc)
|
|
284
361
|
order by l.at desc, l.seq desc limit lim + 1
|
|
@@ -312,14 +389,15 @@ begin
|
|
|
312
389
|
from leaves lv join ch on lv.path[1:cardinality(ch.path)] = ch.path or ch.path[1:cardinality(lv.path)] = lv.path
|
|
313
390
|
group by lv.path
|
|
314
391
|
), oldest as (
|
|
315
|
-
|
|
392
|
+
-- first — самая старая версия окна и есть начало истории объекта
|
|
393
|
+
select w.n, w.rev, w.id = ids[1] and w.rev = los[1] + 1 as first from w order by w.n desc limit 1
|
|
316
394
|
), cut as (
|
|
317
395
|
select count(*) > lim as cut from v
|
|
318
396
|
), fields as (
|
|
319
397
|
select lv.path,
|
|
320
398
|
jsonb_build_object('path', to_jsonb(lv.path), 'rev', cv.rev, 'at', cv.at, 'author', cv.author, 'op', cv.op,
|
|
321
399
|
'exact', case when h.n is not null then not exists (select 1 from gaps g where g.n <= h.n)
|
|
322
|
-
else (select o.
|
|
400
|
+
else (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps) end)
|
|
323
401
|
|| case when cv.id <> p_id then jsonb_build_object('id', cv.id) else '{}'::jsonb end
|
|
324
402
|
|| case when cv.agent is not null then jsonb_build_object('agent', cv.agent) else '{}'::jsonb end
|
|
325
403
|
|| case when cv.reason is not null then jsonb_build_object('reason', cv.reason) else '{}'::jsonb end as obj
|
|
@@ -328,7 +406,7 @@ begin
|
|
|
328
406
|
join w cv on cv.n = coalesce(h.n, (select o.n from oldest o))
|
|
329
407
|
)
|
|
330
408
|
select jsonb_build_object('id', p_id, 'class', t.class, 'rev', t.rev,
|
|
331
|
-
'complete', (select o.
|
|
409
|
+
'complete', (select o.first from oldest o) and not (select c.cut from cut c) and not exists (select 1 from gaps),
|
|
332
410
|
'fields', coalesce((select jsonb_agg(f.obj order by f.path) from fields f), '[]'::jsonb))
|
|
333
411
|
|| case when t.op = 'delete' then '{"deleted": true}'::jsonb else '{}'::jsonb end
|
|
334
412
|
into res;
|
|
@@ -342,7 +420,7 @@ begin
|
|
|
342
420
|
'jsonb_changes(jsonb, jsonb, text[])',
|
|
343
421
|
'row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
|
|
344
422
|
'change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text)',
|
|
345
|
-
'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])',
|
|
423
|
+
'row_leaves(jsonb, jsonb, text[], uuid, text)', 'version_paths(uuid[], int[])', 'history_segments(uuid, int, boolean)',
|
|
346
424
|
'diff_versions(uuid, int, int, timestamptz, timestamptz, boolean)', 'blame(uuid, timestamptz, int)'] loop
|
|
347
425
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
348
426
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
@@ -354,5 +432,5 @@ grant execute on function @SCHEMA@.jsonb_changes(jsonb, jsonb, text[]),
|
|
|
354
432
|
@SCHEMA@.row_changes(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
|
|
355
433
|
@SCHEMA@.change_paths(jsonb, jsonb, text[], uuid, text, jsonb, jsonb, text[], uuid, text),
|
|
356
434
|
@SCHEMA@.row_leaves(jsonb, jsonb, text[], uuid, text), @SCHEMA@.version_paths(uuid[], int[]),
|
|
357
|
-
@SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
|
|
435
|
+
@SCHEMA@.history_segments(uuid, int, boolean), @SCHEMA@.diff_versions(uuid, int, int, timestamptz, timestamptz, boolean),
|
|
358
436
|
@SCHEMA@.blame(uuid, timestamptz, int) to @APP@;
|
package/sql/80-auth.sql
CHANGED
|
@@ -755,7 +755,8 @@ declare
|
|
|
755
755
|
s record;
|
|
756
756
|
begin
|
|
757
757
|
if nullif(current_setting('letopis.account', true), '') is not null then
|
|
758
|
-
|
|
758
|
+
-- код из каталога базы (invalid_query в нём нет — lt_fail дал бы LT000 и сырую ошибку; план 2.0, 7.5 №3)
|
|
759
|
+
perform @SCHEMA@.lt_fail('acl_denied', 'auth.switch — для собственной сессии, не при имперсонации');
|
|
759
760
|
end if;
|
|
760
761
|
select x.id, x.account into s from @SCHEMA@.secret x
|
|
761
762
|
where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(tok, ''), 'UTF8'))
|
package/sql/88-derived.sql
CHANGED
|
@@ -25,7 +25,8 @@ begin
|
|
|
25
25
|
for update;
|
|
26
26
|
if not found then
|
|
27
27
|
update @SCHEMA@.secret s set deleted_at = clock_timestamp()
|
|
28
|
-
where s.kind = 'PROJECTOR' and casefold(s.ident) = casefold(p_name)
|
|
28
|
+
where s.kind = 'PROJECTOR' and casefold(s.ident) collate pg_unicode_fast = casefold(p_name) collate pg_unicode_fast
|
|
29
|
+
and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
|
|
29
30
|
insert into @SCHEMA@.secret (account, kind, ident, meta) values ('@SYSTEM@'::uuid, 'PROJECTOR', p_name, '{}'::jsonb);
|
|
30
31
|
return '{}'::jsonb;
|
|
31
32
|
end if;
|