letopis 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-CHEATSHEET.en.md +14 -2
- package/AGENT-CHEATSHEET.md +14 -2
- package/CHANGELOG.md +186 -0
- package/README.en.md +134 -24
- package/README.md +124 -25
- package/dist/admin.d.ts +4 -0
- package/dist/admin.js +6 -0
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +24 -7
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +11 -3
- package/dist/index.js +122 -18
- package/dist/load.d.ts +63 -0
- package/dist/load.js +293 -37
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +17 -2
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +76 -3
- package/dist/types.js +7 -2
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/watch.d.ts +11 -2
- package/dist/watch.js +24 -6
- package/dist/write.d.ts +10 -1
- package/dist/write.js +32 -5
- package/llms.txt +4 -1
- package/package.json +2 -1
- package/sql/10-core.sql +8 -1
- package/sql/60-write.sql +100 -35
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +3 -3
- package/sql/90-time.sql +45 -9
- package/sql/95-seed.system.sql +12 -4
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +1 -0
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/watch.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
2
|
import type { Row } from './types.js';
|
|
3
|
+
import type { Signal } from './signals.js';
|
|
3
4
|
export interface WatchOptions {
|
|
4
5
|
/** Откуда: курсор события, 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
|
|
5
6
|
from?: string;
|
|
@@ -26,9 +27,17 @@ export interface WatchCtx {
|
|
|
26
27
|
exec: Executor;
|
|
27
28
|
/** Классы подписки с потомками (null — все). */
|
|
28
29
|
classes: string[] | null;
|
|
29
|
-
/** Подписка на сигналы
|
|
30
|
-
onSignal?: (fn: (
|
|
30
|
+
/** Подписка на сигналы базы (разобранные); возвращает отписку. */
|
|
31
|
+
onSignal?: (fn: (s: Signal) => void) => () => void;
|
|
32
|
+
/** Токен арендатора подписки (null — процесс без ключа или режим без токенов). */
|
|
33
|
+
tenant?: () => string | null;
|
|
31
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
37
|
+
* любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
|
|
38
|
+
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
39
|
+
*/
|
|
40
|
+
export declare function wakes(s: Signal, watched: ReadonlySet<string> | null, mine: string | null): boolean;
|
|
32
41
|
export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
33
42
|
private ctx;
|
|
34
43
|
private opts;
|
package/dist/watch.js
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* транзакций (pg_snapshot_xmin) — транзакцию с меньшим номером, зафиксированную позже, курсор уже
|
|
9
9
|
* не обгонит, поэтому событий нет ни потерянных, ни повторных. Поток стоит, пока открыта более
|
|
10
10
|
* старая пишущая транзакция (горизонт общий для кластера); метрика — lag(). Чтение идёт под RLS
|
|
11
|
-
* журнала: каждый видит только своё. Сигнал базы
|
|
12
|
-
*
|
|
11
|
+
* журнала: каждый видит только своё. Сигнал базы только будит подписку: свои классы и свой арендатор
|
|
12
|
+
* (чужие — не будят; процесс без ключа сигналов арендатора не различает, в режиме opaque — и класса);
|
|
13
|
+
* без LISTEN и на случай потери сигнала — запасной опрос.
|
|
13
14
|
*
|
|
14
15
|
* Курсор подписчика старше яруса all политики хранения его классов — cursor_expired: события могли
|
|
15
16
|
* проредиться, нужно перечитать текущее состояние и подписаться заново.
|
|
@@ -28,6 +29,25 @@ function decode(c) {
|
|
|
28
29
|
}
|
|
29
30
|
/** Снимок ещё нужен: курсор не дошёл до его xmax. */
|
|
30
31
|
const keepSnap = (snap, tx) => snap && BigInt(tx) < BigInt(snap.split(':')[1]) ? snap : undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
34
|
+
* любой); сессии не будят; *, opaque без ключа и нагрузка не по режиму — будят. Незнакомый токен класса
|
|
35
|
+
* будит только подписку на все классы: классы подписки с перечнем есть в реестре, их токены известны.
|
|
36
|
+
*/
|
|
37
|
+
export function wakes(s, watched, mine) {
|
|
38
|
+
const ours = (t) => t === null || mine === null || t === mine;
|
|
39
|
+
switch (s.kind) {
|
|
40
|
+
case 'session':
|
|
41
|
+
case 'sessions':
|
|
42
|
+
return false;
|
|
43
|
+
case 'class':
|
|
44
|
+
return (!watched || watched.has(s.cls)) && ours(s.tenant);
|
|
45
|
+
case 'unknown':
|
|
46
|
+
return !watched && ours(s.tenant);
|
|
47
|
+
default:
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
31
51
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
52
|
export class Subscription {
|
|
33
53
|
ctx;
|
|
@@ -60,10 +80,8 @@ export class Subscription {
|
|
|
60
80
|
}
|
|
61
81
|
if (ctx.onSignal) {
|
|
62
82
|
const watched = ctx.classes ? new Set(ctx.classes) : null;
|
|
63
|
-
this.off = ctx.onSignal((
|
|
64
|
-
if (
|
|
65
|
-
return;
|
|
66
|
-
if (watched && name !== '*' && !watched.has(name))
|
|
83
|
+
this.off = ctx.onSignal((s) => {
|
|
84
|
+
if (!wakes(s, watched, ctx.tenant?.() ?? null))
|
|
67
85
|
return;
|
|
68
86
|
this.dirty = true;
|
|
69
87
|
this.wake?.();
|
package/dist/write.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export declare function inc(by: number, opts?: {
|
|
|
17
17
|
start?: number;
|
|
18
18
|
}): IncMarker;
|
|
19
19
|
export declare const isInc: (v: unknown) => v is IncMarker;
|
|
20
|
-
|
|
20
|
+
/** Глагол плана; reason — причина изменения этого глагола (план 1.1, §2.1). */
|
|
21
|
+
export type PlanOp = ({
|
|
21
22
|
kind: 'create';
|
|
22
23
|
data: Record<string, unknown>;
|
|
23
24
|
} | {
|
|
@@ -45,7 +46,11 @@ export type PlanOp = {
|
|
|
45
46
|
} | {
|
|
46
47
|
kind: 'rekey';
|
|
47
48
|
data: Record<string, unknown>;
|
|
49
|
+
}) & {
|
|
50
|
+
reason?: string;
|
|
48
51
|
};
|
|
52
|
+
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
53
|
+
export declare function checkReason(v: unknown, where: string): string;
|
|
49
54
|
/** Значение слота: id, список id, строка ответа или ленивая цепочка (в той же транзакции). */
|
|
50
55
|
export type SlotValue = string | string[] | {
|
|
51
56
|
id: string;
|
|
@@ -81,6 +86,10 @@ export interface WriteCtx {
|
|
|
81
86
|
tenant: string | null;
|
|
82
87
|
/** Владелец новых строк по умолчанию (db.as(…, { owner })). */
|
|
83
88
|
owner?: string;
|
|
89
|
+
/** Причина по умолчанию для глаголов плана и та, что стоит в транзакции; cur — выставленная сейчас. */
|
|
90
|
+
reason?: string;
|
|
91
|
+
txReason?: string;
|
|
92
|
+
cur?: string;
|
|
84
93
|
}
|
|
85
94
|
/** Чтение в транзакции плана — тот же построитель, что у цепочек. */
|
|
86
95
|
export declare function readIn<T>(w: WriteCtx, steps: Step[], mods: ChainMods, mode: ReadMode, agg?: AggSpec): Promise<{
|
package/dist/write.js
CHANGED
|
@@ -35,6 +35,13 @@ export function inc(by, opts = {}) {
|
|
|
35
35
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
36
36
|
}
|
|
37
37
|
export const isInc = (v) => typeof v === 'object' && v !== null && v[INC] === true;
|
|
38
|
+
/** Причина изменения: строка до 1000 символов без NUL (её не принимает set_config). */
|
|
39
|
+
export function checkReason(v, where) {
|
|
40
|
+
if (typeof v !== 'string' || v.length > 1000 || v.includes('\0')) {
|
|
41
|
+
throw new LetopisError('invalid_query', `${where}: reason — строка до 1000 символов`);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
38
45
|
const fail = (code, msg, detail) => {
|
|
39
46
|
throw new LetopisError(code, msg, detail);
|
|
40
47
|
};
|
|
@@ -126,6 +133,8 @@ export function precheckPlan(reg, steps) {
|
|
|
126
133
|
continue;
|
|
127
134
|
}
|
|
128
135
|
const verb = op.kind;
|
|
136
|
+
if (op.reason !== undefined)
|
|
137
|
+
checkReason(op.reason, `${verb}()`);
|
|
129
138
|
const m = s.opMods ?? {};
|
|
130
139
|
if (m.asOf !== undefined)
|
|
131
140
|
fail('invalid_query', `${verb}(): asOf описывает прошлое — записывать в него нельзя`);
|
|
@@ -188,6 +197,14 @@ export function precheckPlan(reg, steps) {
|
|
|
188
197
|
prevOp = op;
|
|
189
198
|
}
|
|
190
199
|
}
|
|
200
|
+
/** letopis.reason перед оператором глагола — только если причина меняется (план 1.1, §2.1). */
|
|
201
|
+
async function useReason(w, want) {
|
|
202
|
+
if (want === (w.cur ?? w.txReason ?? ''))
|
|
203
|
+
return;
|
|
204
|
+
await query(w, `select set_config('letopis.reason', $1, true)`, [want]);
|
|
205
|
+
w.cur = want;
|
|
206
|
+
}
|
|
207
|
+
const reasonFor = (w, s) => s.op?.reason ?? w.reason ?? '';
|
|
191
208
|
const quoteId = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
192
209
|
/** Строка ответа в том же виде, что у чтения (to_jsonb колонок узла). */
|
|
193
210
|
const rowJson = (a) => `jsonb_build_object('id', ${a}.id, 'rev', ${a}.rev, 'class', ${a}.class, 'tenant', ${a}.tenant, 'owner', ${a}.owner, `
|
|
@@ -578,7 +595,8 @@ async function rekeyOp(w, steps, opStep) {
|
|
|
578
595
|
const res = await query(w, `select ${rowJson('e')} as row from ${S}.entity e where e.id = any ($1::uuid[]) order by e.id`, [moved]);
|
|
579
596
|
return res.map((r) => toRow(r.row));
|
|
580
597
|
}
|
|
581
|
-
function opCall(w, steps, opStep) {
|
|
598
|
+
async function opCall(w, steps, opStep) {
|
|
599
|
+
await useReason(w, reasonFor(w, opStep));
|
|
582
600
|
switch (opStep.op.kind) {
|
|
583
601
|
case 'create': return createOp(w, steps, opStep, false);
|
|
584
602
|
case 'upsert': return createOp(w, steps, opStep, true);
|
|
@@ -652,9 +670,11 @@ export async function execPlan(w, steps, mods, mode, agg) {
|
|
|
652
670
|
: kind === 'purge' ? [] : last;
|
|
653
671
|
prevOp = opStep;
|
|
654
672
|
}
|
|
673
|
+
// причина глагола не переходит на следующие операторы транзакции
|
|
674
|
+
await useReason(w, w.reason ?? '');
|
|
655
675
|
const opStep = segments[segments.length - 1].steps.at(-1);
|
|
656
676
|
const key = opStep.aliasKey ?? opStep.name;
|
|
657
|
-
if (tail.length || mode === 'versions' || mode === 'agg') {
|
|
677
|
+
if (tail.length || mode === 'versions' || mode === 'agg' || mode === 'diff' || mode === 'blame') {
|
|
658
678
|
if (!start.length)
|
|
659
679
|
return { kind: 'read', q: null, res: [] };
|
|
660
680
|
const { q, res } = await readIn(w, [fromRows(opStep, start.map((r) => r.id)), ...tail], mods, mode, agg);
|
|
@@ -708,7 +728,10 @@ export async function execBatch(w, plans) {
|
|
|
708
728
|
const group = [];
|
|
709
729
|
const seen = new Set();
|
|
710
730
|
let j = i;
|
|
711
|
-
|
|
731
|
+
// одна причина на оператор: другая причина режет группу
|
|
732
|
+
const why = reasonFor(w, head);
|
|
733
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyed' && plans[j][0].op.kind === verb && plans[j][0].cls.name === head.cls.name
|
|
734
|
+
&& reasonFor(w, plans[j][0]) === why) {
|
|
712
735
|
const s = plans[j][0];
|
|
713
736
|
const id = idFor(s);
|
|
714
737
|
if (!id || (verb === 'upsert' && seen.has(id)))
|
|
@@ -717,6 +740,7 @@ export async function execBatch(w, plans) {
|
|
|
717
740
|
group.push({ at: j, s, id });
|
|
718
741
|
j++;
|
|
719
742
|
}
|
|
743
|
+
await useReason(w, why);
|
|
720
744
|
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
721
745
|
const conflict = verb === 'upsert'
|
|
722
746
|
? 'on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags'
|
|
@@ -738,9 +762,11 @@ export async function execBatch(w, plans) {
|
|
|
738
762
|
}
|
|
739
763
|
else if (kind === 'keyless') {
|
|
740
764
|
let j = i;
|
|
741
|
-
|
|
765
|
+
const why = reasonFor(w, head);
|
|
766
|
+
while (j < plans.length && pureKind(plans[j]) === 'keyless' && reasonFor(w, plans[j][0]) === why)
|
|
742
767
|
j++;
|
|
743
|
-
// однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
|
|
768
|
+
// причина — до конвейера; однострочные вставки без ожидания ответа каждой — postgres.js шлёт их конвейером
|
|
769
|
+
await useReason(w, why);
|
|
744
770
|
const rows = await Promise.all(plans.slice(i, j).map((p) => createOp(w, p, p[0], false)));
|
|
745
771
|
rows.forEach((r, k) => (out[i + k] = r));
|
|
746
772
|
i = j;
|
|
@@ -751,5 +777,6 @@ export async function execBatch(w, plans) {
|
|
|
751
777
|
i++;
|
|
752
778
|
}
|
|
753
779
|
}
|
|
780
|
+
await useReason(w, w.txReason ?? '');
|
|
754
781
|
return out;
|
|
755
782
|
}
|
package/llms.txt
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
> Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
|
|
4
4
|
|
|
5
|
-
Key facts for writing code with letopis 1.
|
|
5
|
+
Key facts for writing code with letopis 1.1:
|
|
6
6
|
|
|
7
7
|
- ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`.
|
|
8
8
|
- Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
|
|
9
9
|
- Classes are hubs (objects) and links (relations with ends), described with `hub()`/`link()` from `letopis/model` and written to the database with `syncModels()` or `letopis sync`. A class with a key gets a deterministic UUID v5 id (`db.idOf`); uniqueness is the key.
|
|
10
10
|
- Verbs return a chain; a terminal (`rows`, `first`, `ids`, `count`, …) executes the whole plan in one transaction. `create` only creates (a taken key is `exists`), `update` merges and never creates, `upsert` replaces the whole object (keyed classes only), `delete()` is a preview and `delete({ confirm: true })` deletes.
|
|
11
11
|
- Money and counters: `update({ balance: inc(n) })` adds inside the statement; `update(patch, { rev })` fails with `conflict` if the row changed; `forUpdate()` locks rows inside `db.begin()`.
|
|
12
|
+
- History: `versions()`, `diff({ from, to })` and `blame()` (who, when and why last changed each field); verbs, `db.begin()` and `db.batch()` take `{ reason }`.
|
|
13
|
+
- Bulk loading from an admin connection: `db.load(rows)`; into an empty schema `db.load(rows, { initial: true })` — one transaction, indexes and triggers rebuilt at the end.
|
|
14
|
+
- NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
|
|
12
15
|
- Access rules are always on and deny by default; `up({ tenant })` or `createTenant()` creates a tenant with the rule "authenticated users may do everything" and its first user.
|
|
13
16
|
- 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), …
|
|
14
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"@electric-sql/pglite-socket": "^0.2.11",
|
|
91
91
|
"@types/node": "^22.20.5",
|
|
92
92
|
"letopis-0": "npm:letopis@0.21.0",
|
|
93
|
+
"letopis-1": "npm:letopis@1.0.1",
|
|
93
94
|
"tsx": "^4.23.15",
|
|
94
95
|
"typescript": "~5.9.3",
|
|
95
96
|
"zod": "4.6.5"
|
package/sql/10-core.sql
CHANGED
|
@@ -86,6 +86,9 @@ create table if not exists @SCHEMA@.log (
|
|
|
86
86
|
prev_hash bytea,
|
|
87
87
|
hash bytea generated always as (@SCHEMA@.version_hash(prev_hash,
|
|
88
88
|
@SCHEMA@.snapshot(id, rev, class, tenant, owner, links, data, tags, at, author, agent, op, reason, moved))) stored,
|
|
89
|
+
-- пути изменения против предыдущей версии (план 1.1, ревизия 6): производные, в снимок и хэш не входят;
|
|
90
|
+
-- null — не посчитано (создание, загрузчик, импорт), blame досчитывает сравнением
|
|
91
|
+
changed jsonb,
|
|
89
92
|
constraint log_id_rev unique (id, rev)
|
|
90
93
|
);
|
|
91
94
|
create index if not exists log_class_at on @SCHEMA@.log (class, at);
|
|
@@ -125,7 +128,11 @@ revoke all on @SCHEMA@.entity, @SCHEMA@.log, @SCHEMA@.secret from public, @APP@;
|
|
|
125
128
|
grant select, delete on @SCHEMA@.entity to @APP@;
|
|
126
129
|
grant insert (id, tenant, class, links, data, tags, owner) on @SCHEMA@.entity to @APP@;
|
|
127
130
|
grant update (class, links, data, tags) on @SCHEMA@.entity to @APP@;
|
|
128
|
-
|
|
131
|
+
-- журнал — без колонки changed (ревью №1 точки А плана 1.1): её пути описывают разницу с предыдущей версией, а та
|
|
132
|
+
-- читателю может быть не видна (условие ACL скрыло её и пропустило следующую); blame получает пути через
|
|
133
|
+
-- version_paths, которая проверяет видимость обеих версий (75-history.sql)
|
|
134
|
+
grant select (seq, tx, id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, hash)
|
|
135
|
+
on @SCHEMA@.log to @APP@;
|
|
129
136
|
alter table @SCHEMA@.entity enable row level security;
|
|
130
137
|
alter table @SCHEMA@.log enable row level security;
|
|
131
138
|
alter table @SCHEMA@.secret enable row level security;
|
package/sql/60-write.sql
CHANGED
|
@@ -260,34 +260,74 @@ language sql stable
|
|
|
260
260
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
261
261
|
return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
|
|
262
262
|
|
|
263
|
-
--
|
|
264
|
-
--
|
|
265
|
-
--
|
|
266
|
-
--
|
|
267
|
-
|
|
263
|
+
-- Сигналы (план 1.1, §2.2): канал — имя схемы, нагрузка — по режиму установки '@SIGNALS@'
|
|
264
|
+
-- (up({ signals })): class — имя класса; tenant — «Класс@токен арендатора»; opaque — «токен класса@токен
|
|
265
|
+
-- арендатора»; none — сигналов нет. Сессии — session:<16 hex>, сброс — * (кроме none). Токен — первые
|
|
266
|
+
-- 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (HMAC ключа строки KEY по
|
|
267
|
+
-- 'letopis:signals', его отдаёт signal_key()): без ключа псевдоним не сопоставить с именем, а
|
|
268
|
+
-- нагрузка детерминирована — PostgreSQL склеивает одинаковые сигналы транзакции.
|
|
269
|
+
-- до плана 1.1 сигнал принимал только имена классов
|
|
270
|
+
drop function if exists @SCHEMA@.signal(text[]);
|
|
271
|
+
|
|
272
|
+
create or replace function @SCHEMA@.signal_token(p_purpose text, p_value text, p_key bytea) returns text
|
|
273
|
+
language sql immutable parallel safe
|
|
274
|
+
return left(encode(@PGCRYPTO@.hmac(convert_to(p_purpose || ':' || p_value, 'UTF8'), p_key, 'sha256'), 'hex'), 24);
|
|
275
|
+
|
|
276
|
+
-- Готовые нагрузки: сигналы записи от signal(), сессии и сброс. Транзакция с NOTIFY фиксируется под
|
|
277
|
+
-- общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
|
|
278
|
+
-- letopis.notify = 'deferred': нагрузки копятся в letopis.changed, а библиотека после фиксации
|
|
279
|
+
-- сбрасывает по ним свой кэш и отправляет их одной транзакцией без данных (в режиме none — не
|
|
280
|
+
-- отправляет). Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией; в режиме none — ничего.
|
|
281
|
+
create or replace function @SCHEMA@.signal_raw(p_payloads text[]) returns void
|
|
268
282
|
language plpgsql volatile
|
|
269
283
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
270
284
|
as $$
|
|
271
285
|
declare
|
|
272
286
|
acc text[];
|
|
273
|
-
|
|
287
|
+
pl text;
|
|
274
288
|
begin
|
|
275
|
-
|
|
289
|
+
p_payloads := array(select distinct x from unnest(p_payloads) x where x <> '' order by x);
|
|
290
|
+
if cardinality(p_payloads) = 0 then
|
|
276
291
|
return;
|
|
277
292
|
end if;
|
|
278
293
|
if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
|
|
279
294
|
acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
|
|
280
|
-
if not (acc @>
|
|
295
|
+
if not (acc @> p_payloads) then
|
|
281
296
|
perform set_config('letopis.changed',
|
|
282
|
-
array_to_string(array(select distinct x from unnest(acc ||
|
|
297
|
+
array_to_string(array(select distinct x from unnest(acc || p_payloads) x order by x), ','), true);
|
|
283
298
|
end if;
|
|
284
|
-
|
|
285
|
-
foreach
|
|
286
|
-
perform pg_notify('@SCHEMA_NAME@',
|
|
299
|
+
elsif '@SIGNALS@' <> 'none' then
|
|
300
|
+
foreach pl in array p_payloads loop
|
|
301
|
+
perform pg_notify('@SCHEMA_NAME@', pl);
|
|
287
302
|
end loop;
|
|
288
303
|
end if;
|
|
289
304
|
end $$;
|
|
290
305
|
|
|
306
|
+
-- Сигнал записи: пары «класс, арендатор» изменённых строк (арендатор null — System) → нагрузки по
|
|
307
|
+
-- режиму. Одинаковые пары склеиваются: транзакция, записавшая тысячу строк одного класса одного
|
|
308
|
+
-- арендатора, посылает один сигнал.
|
|
309
|
+
create or replace function @SCHEMA@.signal(p_classes text[], p_tenants uuid[]) returns void
|
|
310
|
+
language plpgsql volatile
|
|
311
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
312
|
+
as $$
|
|
313
|
+
declare
|
|
314
|
+
k bytea;
|
|
315
|
+
begin
|
|
316
|
+
if p_classes is null or cardinality(p_classes) = 0 then
|
|
317
|
+
return;
|
|
318
|
+
end if;
|
|
319
|
+
if '@SIGNALS@' in ('tenant', 'opaque') then
|
|
320
|
+
select @PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256') into k
|
|
321
|
+
from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
|
|
322
|
+
end if;
|
|
323
|
+
perform @SCHEMA@.signal_raw(array(
|
|
324
|
+
select case '@SIGNALS@'
|
|
325
|
+
when 'tenant' then p.c || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
|
|
326
|
+
when 'opaque' then @SCHEMA@.signal_token('class', p.c, k) || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
|
|
327
|
+
else p.c end
|
|
328
|
+
from (select distinct u.c, coalesce(u.t, '@SYSTEM@'::uuid) as t from unnest(p_classes, p_tenants) u(c, t) where u.c is not null) p));
|
|
329
|
+
end $$;
|
|
330
|
+
|
|
291
331
|
-- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
|
|
292
332
|
-- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
|
|
293
333
|
-- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
|
|
@@ -295,12 +335,19 @@ create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
|
|
|
295
335
|
language plpgsql volatile
|
|
296
336
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
297
337
|
as $$
|
|
338
|
+
declare
|
|
339
|
+
p @SCHEMA@.log;
|
|
298
340
|
begin
|
|
299
341
|
if @SCHEMA@.history_on(e.class, e.tenant) then
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
342
|
+
-- пути изменения: против предыдущей версии id, у первой версии переноса — против прежнего id (moved)
|
|
343
|
+
select l.* into p from @SCHEMA@.log l
|
|
344
|
+
where l.id = case when e.rev > 1 then e.id else e.moved end and l.op not in ('purge', 'trim', 'reset')
|
|
345
|
+
order by l.rev desc limit 1;
|
|
346
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
347
|
+
values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash,
|
|
348
|
+
case when p.id is not null then @SCHEMA@.change_paths(p.data, p.links, p.tags, p.owner, p.class, e.data, e.links, e.tags, e.owner, e.class) end);
|
|
349
|
+
end if;
|
|
350
|
+
perform @SCHEMA@.signal(array[e.class], array[e.tenant]);
|
|
304
351
|
end $$;
|
|
305
352
|
|
|
306
353
|
-- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
|
|
@@ -383,13 +430,16 @@ begin
|
|
|
383
430
|
end $$;
|
|
384
431
|
|
|
385
432
|
-- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
|
|
386
|
-
-- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой
|
|
433
|
+
-- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
|
|
434
|
+
-- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись).
|
|
387
435
|
create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
|
|
388
436
|
language sql immutable
|
|
389
437
|
return
|
|
390
438
|
o->'key' = n->'key'
|
|
391
439
|
and o->'ancestors' = n->'ancestors'
|
|
392
440
|
and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
|
|
441
|
+
and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
|
|
442
|
+
and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
|
|
393
443
|
and coalesce(o->'schema'->'required', '[]'::jsonb) @> coalesce(n->'schema'->'required', '[]'::jsonb)
|
|
394
444
|
and not exists (select 1 from jsonb_each(coalesce(o->'schema'->'properties', '{}'::jsonb)) p
|
|
395
445
|
where n->'schema'->'properties'->p.key is distinct from p.value)
|
|
@@ -701,7 +751,8 @@ language plpgsql security definer
|
|
|
701
751
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
702
752
|
as $$
|
|
703
753
|
declare
|
|
704
|
-
|
|
754
|
+
sc text[];
|
|
755
|
+
st uuid[];
|
|
705
756
|
bad text;
|
|
706
757
|
begin
|
|
707
758
|
if exists (select 1 from added n where n.links <> '{}'::jsonb) then
|
|
@@ -716,9 +767,10 @@ begin
|
|
|
716
767
|
from added n
|
|
717
768
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
|
|
718
769
|
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
770
|
+
-- сигнал — по паре «класс, арендатор»
|
|
771
|
+
select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
|
|
772
|
+
perform @SCHEMA@.signal(sc, st);
|
|
773
|
+
if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
722
774
|
perform @SCHEMA@.acl_bump();
|
|
723
775
|
end if;
|
|
724
776
|
return null;
|
|
@@ -732,7 +784,8 @@ language plpgsql security definer
|
|
|
732
784
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
733
785
|
as $$
|
|
734
786
|
declare
|
|
735
|
-
|
|
787
|
+
sc text[];
|
|
788
|
+
st uuid[];
|
|
736
789
|
o_row @SCHEMA@.entity;
|
|
737
790
|
n_row @SCHEMA@.entity;
|
|
738
791
|
cid uuid;
|
|
@@ -748,15 +801,20 @@ begin
|
|
|
748
801
|
if bad is not null then
|
|
749
802
|
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
750
803
|
end if;
|
|
751
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
752
|
-
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
|
|
804
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
805
|
+
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash,
|
|
806
|
+
@SCHEMA@.change_paths(o.data, o.links, o.tags, o.owner, o.class, n.data, n.links, n.tags, n.owner, n.class)
|
|
753
807
|
from new_rows n
|
|
754
808
|
join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
755
809
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
|
|
756
810
|
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
811
|
+
-- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
|
|
812
|
+
-- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
|
|
813
|
+
select array_agg(k.class), array_agg(k.tenant) into sc, st
|
|
814
|
+
from (select distinct v.class, v.tenant from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
815
|
+
cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(class, tenant)) k;
|
|
816
|
+
perform @SCHEMA@.signal(sc, st);
|
|
817
|
+
if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
760
818
|
perform @SCHEMA@.acl_bump();
|
|
761
819
|
end if;
|
|
762
820
|
for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
@@ -880,6 +938,8 @@ declare
|
|
|
880
938
|
unsets uuid[];
|
|
881
939
|
classes text[];
|
|
882
940
|
lvl_classes text[];
|
|
941
|
+
pairs text[];
|
|
942
|
+
lvl_pairs text[];
|
|
883
943
|
v_author uuid;
|
|
884
944
|
v_reason text := nullif(current_setting('letopis.reason', true), '');
|
|
885
945
|
newlinks jsonb;
|
|
@@ -910,16 +970,18 @@ begin
|
|
|
910
970
|
end loop;
|
|
911
971
|
v_author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
912
972
|
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
|
|
913
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
973
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
914
974
|
select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
|
|
915
|
-
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
|
|
975
|
+
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
|
|
916
976
|
from gone g
|
|
917
977
|
join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
|
|
918
978
|
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
|
|
979
|
+
-- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
|
|
919
980
|
select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
|
|
981
|
+
coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
|
|
920
982
|
coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
|
|
921
983
|
coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
|
|
922
|
-
into lvl, classes, accs, mems from gone g;
|
|
984
|
+
into lvl, classes, pairs, accs, mems from gone g;
|
|
923
985
|
all_ids := lvl;
|
|
924
986
|
while cardinality(lvl) > 0 loop
|
|
925
987
|
-- кто ссылается на уровень: действие по правилам концов (общее с превью)
|
|
@@ -941,24 +1003,26 @@ begin
|
|
|
941
1003
|
where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
|
|
942
1004
|
returning x.*
|
|
943
1005
|
), t as (
|
|
944
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
1006
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
945
1007
|
select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
|
|
946
|
-
greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
|
|
1008
|
+
greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash, '[]'::jsonb
|
|
947
1009
|
from d
|
|
948
1010
|
join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
|
|
949
1011
|
where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
|
|
950
1012
|
)
|
|
951
1013
|
select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
|
|
1014
|
+
coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
|
|
952
1015
|
min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
|
|
953
1016
|
coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
|
|
954
1017
|
coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
|
|
955
|
-
into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
|
|
1018
|
+
into lvl, lvl_classes, lvl_pairs, denied, lvl_accs, lvl_mems from d;
|
|
956
1019
|
if denied is not null then
|
|
957
1020
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
|
|
958
1021
|
end if;
|
|
959
1022
|
accs := accs || lvl_accs;
|
|
960
1023
|
mems := mems || lvl_mems;
|
|
961
1024
|
classes := classes || lvl_classes;
|
|
1025
|
+
pairs := pairs || lvl_pairs;
|
|
962
1026
|
-- unset: снять конец, новая версия
|
|
963
1027
|
for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
|
|
964
1028
|
if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
@@ -987,7 +1051,8 @@ begin
|
|
|
987
1051
|
perform @SCHEMA@.auth_kill(r.acc, null);
|
|
988
1052
|
update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
|
|
989
1053
|
end loop;
|
|
990
|
-
perform @SCHEMA@.signal(
|
|
1054
|
+
perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
|
|
1055
|
+
from (select distinct p from unnest(pairs) p) x;
|
|
991
1056
|
if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
992
1057
|
perform @SCHEMA@.acl_bump();
|
|
993
1058
|
end if;
|
|
@@ -1356,7 +1421,7 @@ begin
|
|
|
1356
1421
|
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
|
|
1357
1422
|
'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
|
|
1358
1423
|
'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
|
|
1359
|
-
'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
|
|
1424
|
+
'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal_token(text, text, bytea)', 'signal_raw(text[])', 'signal(text[], uuid[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
|
|
1360
1425
|
'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
|
|
1361
1426
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
1362
1427
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|