letopis 1.1.0 → 1.3.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 +113 -16
- package/AGENT-CHEATSHEET.md +106 -14
- package/CHANGELOG.md +702 -0
- package/README.en.md +798 -74
- package/README.md +756 -68
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +94 -23
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +318 -0
- package/dist/project.js +1142 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/sync.js +11 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +48 -4
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +13 -2
- package/sql/10-core.sql +8 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +629 -56
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +154 -13
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
package/dist/registry.d.ts
CHANGED
|
@@ -9,6 +9,17 @@ export interface EndInfo {
|
|
|
9
9
|
onDelete?: 'restrict' | 'cascade' | 'unset';
|
|
10
10
|
meta?: Record<string, unknown>;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Агрегат в базе (план 1.1, §2.4): поле — сумма поля sum или число (count) живых строк класса of с потомками того же
|
|
14
|
+
* арендатора, чей одиночный конец via ведёт на строку; where — JSON Schema над данными строки-источника.
|
|
15
|
+
*/
|
|
16
|
+
export interface AggregateInfo {
|
|
17
|
+
sum?: string;
|
|
18
|
+
count?: true;
|
|
19
|
+
of: string;
|
|
20
|
+
via: string;
|
|
21
|
+
where?: JsonSchema;
|
|
22
|
+
}
|
|
12
23
|
/** Тип листа данных для фильтров, сортировки и агрегатов (замена fieldTypeOf 0.21). */
|
|
13
24
|
export type LeafType = 'text' | 'numeric' | 'boolean' | 'timestamptz' | 'date' | 'uuid' | 'jsonb';
|
|
14
25
|
export interface ClassInfo {
|
|
@@ -29,6 +40,10 @@ export interface ClassInfo {
|
|
|
29
40
|
ttl?: number;
|
|
30
41
|
};
|
|
31
42
|
meta: Record<string, unknown>;
|
|
43
|
+
/** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
|
|
44
|
+
derivedBy?: string;
|
|
45
|
+
/** Агрегатные поля: их считает база, приложение не пишет (план 1.1, §2.4). */
|
|
46
|
+
aggregate?: Record<string, AggregateInfo>;
|
|
32
47
|
/** Валидатор на TypeScript (компилируется при первом обращении). */
|
|
33
48
|
validator(): Validator;
|
|
34
49
|
}
|
|
@@ -40,6 +55,8 @@ interface ClassRow {
|
|
|
40
55
|
resolved: Record<string, unknown>;
|
|
41
56
|
};
|
|
42
57
|
}
|
|
58
|
+
/** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
|
|
59
|
+
export declare const aggregateFields: (cls: Pick<ClassInfo, "aggregate">) => string[];
|
|
43
60
|
export declare class Registry {
|
|
44
61
|
private byName;
|
|
45
62
|
private byAlias;
|
package/dist/registry.js
CHANGED
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { LetopisError } from './errors.js';
|
|
7
7
|
import { compileValidator } from './validate.js';
|
|
8
|
+
/** Агрегатные поля класса: их считает база (план 1.1, §2.4). */
|
|
9
|
+
export const aggregateFields = (cls) => Object.keys(cls.aggregate ?? {});
|
|
10
|
+
/** Описание агрегатов из resolved — без скомпилированного условия (match): оно для базы. */
|
|
11
|
+
function aggregateOf(agg) {
|
|
12
|
+
return Object.fromEntries(Object.entries(agg).map(([field, d]) => [field, {
|
|
13
|
+
...(typeof d.sum === 'string' ? { sum: d.sum } : { count: true }),
|
|
14
|
+
of: d.of,
|
|
15
|
+
via: d.via,
|
|
16
|
+
...(d.where && typeof d.where === 'object' ? { where: d.where } : {}),
|
|
17
|
+
}]));
|
|
18
|
+
}
|
|
8
19
|
export class Registry {
|
|
9
20
|
byName = new Map();
|
|
10
21
|
byAlias = new Map();
|
|
@@ -30,6 +41,8 @@ export class Registry {
|
|
|
30
41
|
ownerDefault: (res.ownerDefault ?? 'tenant'),
|
|
31
42
|
cache: (res.cache ?? undefined),
|
|
32
43
|
meta: (res.meta ?? {}),
|
|
44
|
+
...(typeof res.derivedBy === 'string' ? { derivedBy: res.derivedBy } : {}),
|
|
45
|
+
...(res.aggregate && typeof res.aggregate === 'object' ? { aggregate: aggregateOf(res.aggregate) } : {}),
|
|
33
46
|
validator: () => (compiled ??= compileValidator(res.schema)),
|
|
34
47
|
};
|
|
35
48
|
this.byName.set(info.name, info);
|
package/dist/sql.js
CHANGED
|
@@ -693,14 +693,17 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
693
693
|
case 'countPaths':
|
|
694
694
|
text = `${withClause}select count(*)::int as n ${from}${whereOf(tail)}`;
|
|
695
695
|
break;
|
|
696
|
-
case 'versions':
|
|
697
|
-
// с
|
|
696
|
+
case 'versions': {
|
|
697
|
+
// с sort — по полю и направлению (последние версии первыми — sort('at', 'desc'), с limit — в самом запросе);
|
|
698
|
+
// без sort: с follow — по времени через все связанные id, без follow — по id и номеру версии
|
|
699
|
+
const order = sort ? `${sort.expr('v')}${dir}, v.id${dir}, v.rev${dir}` : mods.follow ? 'v.at, v.id, v.rev' : 'v.id, v.rev';
|
|
698
700
|
text = mods.follow
|
|
699
701
|
? `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
700
|
-
+ `where l.id in (select f.id from f)) v order by
|
|
702
|
+
+ `where l.id in (select f.id from f)) v order by ${order}${limitOffset()}`
|
|
701
703
|
: `${withClause}select to_jsonb(v) as row from (select ${colsOf('l')}, null::int as depth from ${S}.log l `
|
|
702
|
-
+ `where l.id in (select z.id from ${ents} z)) v order by
|
|
704
|
+
+ `where l.id in (select z.id from ${ents} z)) v order by ${order}${limitOffset()}`;
|
|
703
705
|
break;
|
|
706
|
+
}
|
|
704
707
|
case 'diff':
|
|
705
708
|
case 'blame': {
|
|
706
709
|
// план 1.1, §2.1: функции базы считают по журналу под RLS — по одному документу на объект шага
|
|
@@ -720,7 +723,8 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
|
|
|
720
723
|
const acc = accessor(path)('z');
|
|
721
724
|
aggLeaf = leaf(path);
|
|
722
725
|
if (agg.fn === 'countBy') {
|
|
723
|
-
|
|
726
|
+
// limit и offset — группы по убыванию числа (самые частые значения), а не строки
|
|
727
|
+
text = `${withClause}select ${acc} as k, count(*)::int as n from ${ents} z group by 1 order by 2 desc, 1${limitOffset()}`;
|
|
724
728
|
}
|
|
725
729
|
else {
|
|
726
730
|
const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
|
package/dist/sync.js
CHANGED
|
@@ -11,13 +11,22 @@ function canon(v) {
|
|
|
11
11
|
export async function syncModels(db, models, opts = {}) {
|
|
12
12
|
const res = { applied: Boolean(opts.apply), created: [], updated: [], unchanged: [], skipped: [], lost: models.flatMap((m) => m.lostRules()) };
|
|
13
13
|
const S = `"${db.schema.replace(/"/g, '""')}"`;
|
|
14
|
-
// предок раньше
|
|
14
|
+
// предок раньше потомка; класс-источник агрегата раньше цели: агрегат над классом, которого ещё нет, база отклоняет.
|
|
15
|
+
// Цикл агрегатов база отклоняет сама — обход его только не зацикливает
|
|
15
16
|
const ordered = [];
|
|
17
|
+
const visiting = new Set();
|
|
16
18
|
const visit = (m) => {
|
|
17
|
-
if (ordered.includes(m))
|
|
19
|
+
if (ordered.includes(m) || visiting.has(m))
|
|
18
20
|
return;
|
|
21
|
+
visiting.add(m);
|
|
19
22
|
if (m.parent && models.includes(m.parent))
|
|
20
23
|
visit(m.parent);
|
|
24
|
+
for (const a of Object.values(m.describe().aggregate ?? {})) {
|
|
25
|
+
const src = models.find((x) => x.name === a.of);
|
|
26
|
+
if (src)
|
|
27
|
+
visit(src);
|
|
28
|
+
}
|
|
29
|
+
visiting.delete(m);
|
|
21
30
|
ordered.push(m);
|
|
22
31
|
};
|
|
23
32
|
models.forEach(visit);
|
package/dist/types.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare const ACL_CLASS_NAMES: readonly string[];
|
|
|
11
11
|
/** Методы цепочки: терминалы, модификаторы, глаголы записи (этап 4), охранники снесённого API. */
|
|
12
12
|
export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account", "diff", "blame"];
|
|
13
13
|
/** Свойства корня db (и транзакции db.begin()). */
|
|
14
|
-
export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache"];
|
|
14
|
+
export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache", "projector", "rebuildAggregates"];
|
|
15
15
|
/** Свойства батча db.batch(name). */
|
|
16
16
|
export declare const BATCH_METHODS: readonly ["discard", "size"];
|
|
17
17
|
/**
|
package/dist/types.js
CHANGED
|
@@ -23,7 +23,7 @@ export const DB_METHODS = [
|
|
|
23
23
|
'as', 'begin', 'commit', 'rollback', 'lock', 'batch', 'watch', 'close', 'registry', 'sql', 'load', 'verify',
|
|
24
24
|
'maintain', 'reset', 'describe', 'auth', 'acl', 'idOf', 'refresh', 'transaction', 'flushSignals', 'schema',
|
|
25
25
|
'token', 'tenant', 'reloadSchema', 'accounts', 'credentials', 'resources', 'rules', 'compact',
|
|
26
|
-
'trimHistory', 'rekeyClass', 'cache',
|
|
26
|
+
'trimHistory', 'rekeyClass', 'cache', 'projector', 'rebuildAggregates',
|
|
27
27
|
];
|
|
28
28
|
/** Свойства батча db.batch(name). */
|
|
29
29
|
export const BATCH_METHODS = ['discard', 'size'];
|
package/dist/uuid.js
CHANGED
|
@@ -5,18 +5,29 @@
|
|
|
5
5
|
* (`v5_name` в lib/sql/30-validate.sql — текстовый вид jsonb-массива): элементы через JSON.stringify,
|
|
6
6
|
* разделитель — запятая с пробелом. Совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { hash } from 'node:crypto';
|
|
9
9
|
import { LetopisError } from './errors.js';
|
|
10
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
// байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
|
|
12
|
+
const NS = new Map();
|
|
13
|
+
function nsBytes(ns) {
|
|
14
|
+
let b = NS.get(ns);
|
|
15
|
+
if (!b) {
|
|
16
|
+
if (!UUID.test(ns))
|
|
17
|
+
throw new LetopisError('invalid_data', `пространство имён v5 — не uuid: ${ns}`);
|
|
18
|
+
b = Buffer.from(ns.replace(/-/g, ''), 'hex');
|
|
19
|
+
if (NS.size >= 10_000)
|
|
20
|
+
NS.clear();
|
|
21
|
+
NS.set(ns, b);
|
|
22
|
+
}
|
|
23
|
+
return b;
|
|
24
|
+
}
|
|
11
25
|
/** RFC 4122 v5: sha1(пространство + имя); одно имя в одном пространстве — один id. */
|
|
12
26
|
export function uuidv5(name, ns) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
h[8] = (h[8] & 0x3f) | 0x80; // вариант 10xx
|
|
18
|
-
const x = h.toString('hex');
|
|
19
|
-
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
|
27
|
+
const x = hash('sha1', Buffer.concat([nsBytes(ns), Buffer.from(name, 'utf8')]), 'hex');
|
|
28
|
+
// версия 5 — старшая тетрада байта 6; вариант 10xx — старшие биты байта 8
|
|
29
|
+
const variant = ((parseInt(x[16], 16) & 0x3) | 0x8).toString(16);
|
|
30
|
+
return `${x.slice(0, 8)}-${x.slice(8, 12)}-5${x.slice(13, 16)}-${variant}${x.slice(17, 20)}-${x.slice(20, 32)}`;
|
|
20
31
|
}
|
|
21
32
|
/** Имя v5: JSON-массив из имени класса и значений ключа — как `jsonb_build_array(…)::text` в базе. */
|
|
22
33
|
export function v5Name(cls, values) {
|
package/dist/watch.d.ts
CHANGED
|
@@ -53,13 +53,25 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
53
53
|
private readonly pollMs;
|
|
54
54
|
/** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
|
|
55
55
|
private start;
|
|
56
|
+
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
57
|
+
private queue;
|
|
56
58
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
57
59
|
/** Курсор последнего выданного события (или начальной точки). */
|
|
58
60
|
get cursor(): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
|
|
63
|
+
* у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
|
|
64
|
+
*/
|
|
65
|
+
ready(): Promise<void>;
|
|
59
66
|
private head;
|
|
60
67
|
/** Следующая пачка событий ниже горизонта; пусто — горизонт ещё не дошёл или событий нет. */
|
|
61
68
|
private fetch;
|
|
69
|
+
/**
|
|
70
|
+
* Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
|
|
71
|
+
* брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
|
|
72
|
+
*/
|
|
62
73
|
next(): Promise<IteratorResult<WatchEvent>>;
|
|
74
|
+
private take;
|
|
63
75
|
/**
|
|
64
76
|
* Отставание, мс: сколько ждёт самое старое событие, которое уже зафиксировано, но ещё не ушло за
|
|
65
77
|
* горизонт (его держит более старая открытая транзакция); 0 — ждать нечего.
|
package/dist/watch.js
CHANGED
|
@@ -64,6 +64,8 @@ export class Subscription {
|
|
|
64
64
|
pollMs;
|
|
65
65
|
/** 'now' — голова журнала в момент подписки (запрос уходит сразу). */
|
|
66
66
|
start = null;
|
|
67
|
+
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
68
|
+
queue = Promise.resolve();
|
|
67
69
|
constructor(ctx, opts) {
|
|
68
70
|
this.ctx = ctx;
|
|
69
71
|
this.opts = opts;
|
|
@@ -97,6 +99,14 @@ export class Subscription {
|
|
|
97
99
|
get cursor() {
|
|
98
100
|
return this.pos ? encode(this.pos) : null;
|
|
99
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Начальная точка взята (доработки после точки Б): у from: 'now' — горизонт и снимок момента подписки (watch_head());
|
|
104
|
+
* у 'start' и курсора — сразу. Запись после await sub.ready() придёт в поток; ошибка начальной точки — отказ.
|
|
105
|
+
*/
|
|
106
|
+
async ready() {
|
|
107
|
+
if (!this.pos && this.start)
|
|
108
|
+
this.pos = await this.start;
|
|
109
|
+
}
|
|
100
110
|
async head() {
|
|
101
111
|
const [r] = await this.ctx.exec.run((q) => q.unsafe(`select tx::text as tx, seq::text as seq, snap::text as snap from ${this.ctx.S}.watch_head()`), { read: true });
|
|
102
112
|
return { tx: r.tx, seq: r.seq, at: null, snap: r.snap };
|
|
@@ -106,10 +116,35 @@ export class Subscription {
|
|
|
106
116
|
if (!this.pos)
|
|
107
117
|
this.pos = await (this.start ?? this.head());
|
|
108
118
|
const p = this.pos;
|
|
109
|
-
const
|
|
119
|
+
const cols = `l.tx::text as tx, l.seq::text as seq, jsonb_build_object('id', l.id, 'rev', l.rev, 'class', l.class, 'tenant', l.tenant, `
|
|
110
120
|
+ `'owner', l.owner, 'links', l.links, 'data', l.data, 'tags', l.tags, 'at', l.at, 'author', l.author, 'agent', l.agent, `
|
|
111
|
-
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row
|
|
112
|
-
|
|
121
|
+
+ `'op', l.op, 'reason', l.reason, 'moved', l.moved) as row`;
|
|
122
|
+
// уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3) — надгробием прежней
|
|
123
|
+
// версии в том же окне: полная пачка watch_read ограничивает окно своей последней строкой. Оба потока — одним
|
|
124
|
+
// оператором: общий снимок и горизонт, иначе курсор перескочил бы событие, зафиксированное между запросами
|
|
125
|
+
// (автоматическое ревью). Потоки сливаются по (tx, seq) до границы — последней строки потока, обрезанного лимитом
|
|
126
|
+
const S = this.ctx.S;
|
|
127
|
+
const both = await this.ctx.exec.run((q) => q.unsafe(`with r as (select l.* from ${S}.watch_read($1::text::xid8, $2::bigint, $3::text::timestamptz, $4::text[], $5::int, $6::text::pg_snapshot) l),
|
|
128
|
+
u as (select r.tx, r.seq from r where (select count(*) from r) = $5 order by r.tx desc, r.seq desc limit 1)
|
|
129
|
+
select 0 as src, ${cols} from r l
|
|
130
|
+
union all
|
|
131
|
+
select 1 as src, ${cols} from ${S}.watch_leaving($1::text::xid8, $2::bigint, $4::text[], $5::int, $6::text::pg_snapshot,
|
|
132
|
+
(select tx from u), (select seq from u)) l`, [p.tx, p.seq, p.at, this.ctx.classes, this.batch, p.snap ?? null]), { read: true });
|
|
133
|
+
const before = (a, b) => BigInt(a.tx) < BigInt(b.tx) || (a.tx === b.tx && BigInt(a.seq) < BigInt(b.seq));
|
|
134
|
+
const order = (a, b) => (before(a, b) ? -1 : before(b, a) ? 1 : 0);
|
|
135
|
+
const read = both.filter((r) => r.src === 0).sort(order);
|
|
136
|
+
const away = both.filter((r) => r.src === 1).sort(order);
|
|
137
|
+
let rows = read;
|
|
138
|
+
{
|
|
139
|
+
const upto = read.length === this.batch ? read[read.length - 1] : null;
|
|
140
|
+
if (away.length) {
|
|
141
|
+
let bound = upto;
|
|
142
|
+
const lastAway = away[away.length - 1];
|
|
143
|
+
if (away.length === this.batch && (!bound || before(lastAway, bound)))
|
|
144
|
+
bound = lastAway;
|
|
145
|
+
rows = [...read, ...away].sort(order).filter((r) => !bound || !before(bound, r));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
113
148
|
for (const r of rows) {
|
|
114
149
|
const row = toRow(r.row);
|
|
115
150
|
const snap = keepSnap(p.snap, r.tx);
|
|
@@ -118,7 +153,16 @@ export class Subscription {
|
|
|
118
153
|
}
|
|
119
154
|
return rows.length;
|
|
120
155
|
}
|
|
121
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Следующее событие. Вызовы идут по очереди: параллельные next() получают разные события, без двойных. Вызов,
|
|
158
|
+
* брошенный по таймауту (Promise.race), всё равно получит своё событие — держите его обещание.
|
|
159
|
+
*/
|
|
160
|
+
next() {
|
|
161
|
+
const p = this.queue.then(() => this.take(), () => this.take());
|
|
162
|
+
this.queue = p.catch(() => undefined);
|
|
163
|
+
return p;
|
|
164
|
+
}
|
|
165
|
+
async take() {
|
|
122
166
|
let empty = 0;
|
|
123
167
|
for (;;) {
|
|
124
168
|
if (this.failure)
|
package/dist/write.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ClassInfo, type Registry } from './registry.js';
|
|
2
2
|
import { type AggSpec, type BuiltQuery, type ReadMode, type Step } from './sql.js';
|
|
3
3
|
import type { Tx } from './tx.js';
|
|
4
4
|
import type { ChainMods, Row } from './types.js';
|
package/dist/write.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { LetopisError, ValidationError } from './errors.js';
|
|
21
21
|
import { isOp } from './ops.js';
|
|
22
|
+
import { aggregateFields } from './registry.js';
|
|
22
23
|
import { accepts, buildRead, nextNodeKey, resolveHop, stepClasses, toRow } from './sql.js';
|
|
23
24
|
import { idOf } from './uuid.js';
|
|
24
25
|
import { applyDefaults } from './validate.js';
|
|
@@ -115,13 +116,26 @@ function checkFullData(cls, data, verb, full = true) {
|
|
|
115
116
|
scan(x, [...path, k]);
|
|
116
117
|
};
|
|
117
118
|
scan(data, []);
|
|
119
|
+
// агрегатные поля считает база (план 1.1, §2.4): переданное значение — aggregate_field до отправки, а для ранней
|
|
120
|
+
// проверки поле — 0, как его поставит база
|
|
121
|
+
const obj = data;
|
|
122
|
+
const agg = aggregateFields(cls);
|
|
123
|
+
const given = agg.filter((f) => f in obj);
|
|
124
|
+
if (given.length)
|
|
125
|
+
fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — их не передают`);
|
|
118
126
|
// описания системных классов проверяет только база (метасхема Class, class_admit)
|
|
119
127
|
if (!full || cls.name === 'Class' || cls.abstract)
|
|
120
128
|
return;
|
|
121
|
-
const issues = cls.validator()(applyDefaults(cls.schema,
|
|
129
|
+
const issues = cls.validator()(applyDefaults(cls.schema, agg.length ? { ...obj, ...Object.fromEntries(agg.map((f) => [f, 0])) } : obj));
|
|
122
130
|
if (issues.length)
|
|
123
131
|
throw new ValidationError(cls.name, issues);
|
|
124
132
|
}
|
|
133
|
+
/** Патч update не трогает агрегатные поля класса шага (план 1.1, §2.4): их считает база. */
|
|
134
|
+
function checkAggregatePatch(cls, patch) {
|
|
135
|
+
const given = aggregateFields(cls).filter((f) => f in patch);
|
|
136
|
+
if (given.length)
|
|
137
|
+
fail('aggregate_field', `поля ${given.join(', ')} класса ${cls.name} считает база (aggregate) — update их не меняет`);
|
|
138
|
+
}
|
|
125
139
|
/** Всё, что проверяется без базы, — до отправки первого оператора (§2.5). */
|
|
126
140
|
export function precheckPlan(reg, steps) {
|
|
127
141
|
let prevOp;
|
|
@@ -162,6 +176,7 @@ export function precheckPlan(reg, steps) {
|
|
|
162
176
|
if (!isPlain(op.data))
|
|
163
177
|
fail('invalid_data', 'update(): патч — объект');
|
|
164
178
|
splitPatch(op.data);
|
|
179
|
+
checkAggregatePatch(s.cls, op.data);
|
|
165
180
|
if (op.rev !== undefined && (!Number.isInteger(op.rev) || op.rev < 1))
|
|
166
181
|
fail('invalid_query', 'update(patch, { rev }): rev — номер версии строки');
|
|
167
182
|
break;
|
|
@@ -388,13 +403,16 @@ async function createOp(w, steps, opStep, upsert) {
|
|
|
388
403
|
const links = applySlotsLocal(await pathEnds(w, steps, new Set(slots.map((x) => x.role))), slots);
|
|
389
404
|
const given = typeof opStep.filter === 'string' ? opStep.filter : undefined;
|
|
390
405
|
const owner = opStep.ownerFilter ?? w.owner ?? null;
|
|
406
|
+
// агрегатные поля (план 1.1, §2.4): в excluded их поставил триггер вставки (0) — замена данных идёт без них, и
|
|
407
|
+
// триггер правки возвращает прежние значения
|
|
408
|
+
const agg = aggregateFields(cls);
|
|
391
409
|
const conflict = upsert
|
|
392
|
-
? ` on conflict (id) do update set data = excluded.data, links = excluded.links, tags = excluded.tags`
|
|
410
|
+
? ` on conflict (id) do update set data = excluded.data${agg.length ? ' - $7::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
393
411
|
: '';
|
|
394
412
|
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)`
|
|
395
413
|
+ `${conflict} returning old.rev as old_rev, ${rowJson('new')} as row`;
|
|
396
414
|
try {
|
|
397
|
-
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner]);
|
|
415
|
+
const res = await query(w, text, [given ?? null, cls.name, links, op.data, tagsValue(opStep), owner, ...(upsert && agg.length ? [agg] : [])]);
|
|
398
416
|
return res.map((r) => {
|
|
399
417
|
const row = toRow(r.row);
|
|
400
418
|
if (upsert)
|
|
@@ -742,11 +760,13 @@ export async function execBatch(w, plans) {
|
|
|
742
760
|
}
|
|
743
761
|
await useReason(w, why);
|
|
744
762
|
const input = group.map((g) => ({ d: g.s.op.data, t: tagsValue(g.s), o: g.s.ownerFilter ?? w.owner ?? null }));
|
|
763
|
+
// агрегатные поля — без них, как у одиночного upsert (план 1.1, §2.4)
|
|
764
|
+
const agg = aggregateFields(head.cls);
|
|
745
765
|
const conflict = verb === 'upsert'
|
|
746
|
-
?
|
|
766
|
+
? `on conflict (id) do update set data = excluded.data${agg.length ? ' - $3::text[]' : ''}, links = excluded.links, tags = excluded.tags`
|
|
747
767
|
: 'on conflict (id) do nothing';
|
|
748
768
|
const res = await query(w, `insert into ${S}.entity (class, data, tags, owner) select $1::text, x.d, x.t, x.o `
|
|
749
|
-
+ `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]);
|
|
769
|
+
+ `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, ...(verb === 'upsert' && agg.length ? [agg] : [])]);
|
|
750
770
|
const byId = new Map(res.map((r) => [r.row.id, r]));
|
|
751
771
|
for (const g of group) {
|
|
752
772
|
const r = byId.get(g.id);
|
package/llms.txt
CHANGED
|
@@ -2,17 +2,21 @@
|
|
|
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.3:
|
|
6
6
|
|
|
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`.
|
|
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`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
|
|
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
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
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
|
+
- Projectors from an admin connection: `db.projector(name, { from, into, version, handle })` builds rows of derived classes (`derivedBy: name`) from the journal; each batch is one transaction and every change is applied exactly once; `run()`, `rebuild()`, `status()`. Only the projector writes derived rows: the application, raw SQL and the loader get `derived_class`. Only an administrator creates, changes and deletes derived classes (an admin connection with a tenant session, or `letopis sync` with the admin `--dsn`); for an application session it is `acl_denied`. `purge()` of a deleted source object waits until the projector has read the deletion (`projector_pending`).
|
|
15
|
+
- Aggregates in the database: the class option `aggregate: { balance: { sum: 'amount', of: 'Movement', via: 'Wallet' } }` (or `count: true`, `where` as a JSON Schema) keeps the field equal to the total of live source rows of the same tenant; a trigger after each statement recalculates it for the library, raw SQL, cascades and the loader; only the database writes the field (`aggregate_field`); repair with `db.rebuildAggregates(class)` on an admin connection, check with `db.verify({ data: true })`.
|
|
16
|
+
- Subscriptions: `db.watch({ from, classes })` — `await sub.ready()` before a write that must reach the stream (`from: 'now'` fixes its starting point with the first query); parallel `next()` calls get different events.
|
|
14
17
|
- NOTIFY signals are visible to any database role: `up({ signals: 'class' | 'tenant' | 'opaque' | 'none' })` chooses what they reveal (default `class`).
|
|
15
18
|
- 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.
|
|
19
|
+
- MCP: `createMcpServer(db)` from `letopis/mcp` and `letopis mcp` (stdio, a user session token only) give an AI agent the tools `letopis_model`, `letopis_query` (a JSON chain, no SQL), `letopis_history` and, with `write: true`, `letopis_write`; the agent acts with the rights of one user, and a deletion needs a human confirmation through elicitation; by default `letopis_write` does not write the permission classes (`allowAclWrite`), and a session in the System tenant is refused (`allowSystemAdmin`). `structuredContent` of a tool result is `{ notice, data }` (an error — `{ notice, error }`): `notice` warns that `data` holds untrusted stored data, not instructions.
|
|
16
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), …
|
|
17
21
|
|
|
18
22
|
## Docs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"./model": {
|
|
36
36
|
"types": "./dist/model.d.ts",
|
|
37
37
|
"import": "./dist/model.js"
|
|
38
|
+
},
|
|
39
|
+
"./mcp": {
|
|
40
|
+
"types": "./dist/mcp.d.ts",
|
|
41
|
+
"import": "./dist/mcp.js"
|
|
38
42
|
}
|
|
39
43
|
},
|
|
40
44
|
"engines": {
|
|
@@ -72,7 +76,8 @@
|
|
|
72
76
|
"peerDependencies": {
|
|
73
77
|
"zod": "^4",
|
|
74
78
|
"@electric-sql/pglite": "^0.5.8",
|
|
75
|
-
"@electric-sql/pglite-socket": "^0.2.11"
|
|
79
|
+
"@electric-sql/pglite-socket": "^0.2.11",
|
|
80
|
+
"@modelcontextprotocol/server": "^2.4.0"
|
|
76
81
|
},
|
|
77
82
|
"peerDependenciesMeta": {
|
|
78
83
|
"zod": {
|
|
@@ -83,14 +88,20 @@
|
|
|
83
88
|
},
|
|
84
89
|
"@electric-sql/pglite-socket": {
|
|
85
90
|
"optional": true
|
|
91
|
+
},
|
|
92
|
+
"@modelcontextprotocol/server": {
|
|
93
|
+
"optional": true
|
|
86
94
|
}
|
|
87
95
|
},
|
|
88
96
|
"devDependencies": {
|
|
89
97
|
"@electric-sql/pglite": "^0.5.8",
|
|
90
98
|
"@electric-sql/pglite-socket": "^0.2.11",
|
|
99
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
100
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
91
101
|
"@types/node": "^22.20.5",
|
|
92
102
|
"letopis-0": "npm:letopis@0.21.0",
|
|
93
103
|
"letopis-1": "npm:letopis@1.0.1",
|
|
104
|
+
"letopis-11": "npm:letopis@1.1.0",
|
|
94
105
|
"tsx": "^4.23.15",
|
|
95
106
|
"typescript": "~5.9.3",
|
|
96
107
|
"zod": "4.6.5"
|
package/sql/10-core.sql
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
revoke all on schema @SCHEMA@ from public;
|
|
7
7
|
grant usage on schema @SCHEMA@ to @APP@;
|
|
8
8
|
|
|
9
|
-
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}.
|
|
9
|
+
-- Все цели концов плоским списком: {роль: id} или {роль: [id, …]}. Тело — SQL-стандарта (return): имена связываются
|
|
10
|
+
-- при создании и от search_path сеанса не зависят, а колонку ends вычисляет роль приложения. Вариант на plpgsql быстрее
|
|
11
|
+
-- на 17 % (бенч gencols), но его имена разрешались бы при исполнении (ревью безопасности доработок после 1.2.0)
|
|
10
12
|
create or replace function @SCHEMA@.link_ids(links jsonb) returns uuid[]
|
|
11
13
|
language sql immutable parallel safe
|
|
12
14
|
return (
|
|
@@ -91,7 +93,11 @@ create table if not exists @SCHEMA@.log (
|
|
|
91
93
|
changed jsonb,
|
|
92
94
|
constraint log_id_rev unique (id, rev)
|
|
93
95
|
);
|
|
94
|
-
|
|
96
|
+
-- срез журнала по классу (asOf, withDeleted) — по арендатору (доработки после точки Б): цена запроса не растёт с
|
|
97
|
+
-- журналом других арендаторов с тем же именем класса. Прежний log_class_at (class, at) снимается при накате; на
|
|
98
|
+
-- большой установке новый индекс можно построить заранее — create index concurrently log_class_tenant_at …
|
|
99
|
+
create index if not exists log_class_tenant_at on @SCHEMA@.log (class, tenant, at);
|
|
100
|
+
drop index if exists @SCHEMA@.log_class_at;
|
|
95
101
|
create index if not exists log_tx_seq on @SCHEMA@.log (tx, seq);
|
|
96
102
|
-- versions({ follow: true }): переход по связи старого и нового id при reclass и rekey (этап 6)
|
|
97
103
|
create index if not exists log_moved on @SCHEMA@.log (moved) where moved is not null;
|
package/sql/15-errors.sql
CHANGED
|
@@ -25,7 +25,10 @@ as $$
|
|
|
25
25
|
('LT017', 'delete_restricted', 'letopis: delete_restricted: удалению мешают ссылающиеся строки'),
|
|
26
26
|
('LT018', 'not_deleted', 'letopis: not_deleted: объект не удалён — restore и purge только для удалённых'),
|
|
27
27
|
('LT019', 'reset_disabled', 'letopis: reset_disabled: reset выключен в этой установке'),
|
|
28
|
-
('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы')
|
|
28
|
+
('LT020', 'confirm_mismatch', 'letopis: confirm_mismatch: подтверждение не совпадает с именем схемы'),
|
|
29
|
+
('LT021', 'derived_class', 'letopis: derived_class: строки производного класса пишет только его проектор'),
|
|
30
|
+
('LT022', 'aggregate_field', 'letopis: aggregate_field: агрегатное поле считает база'),
|
|
31
|
+
('LT023', 'projector_pending', 'letopis: projector_pending: проектор источника ещё не прочёл удаление')
|
|
29
32
|
$$;
|
|
30
33
|
|
|
31
34
|
create or replace function @SCHEMA@.lt_raise(p_code text, p_name text, p_msg text, p_detail jsonb default null)
|
package/sql/40-class.sql
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
-- key [<поле или роль>…] — из них вычисляется id (§2.3); [] — класс без ключа
|
|
13
13
|
-- history true | false | политика хранения (§2.11)
|
|
14
14
|
-- ownerDefault 'tenant' | 'actor'; cache { ttl }; meta {…} до 64 КБ
|
|
15
|
-
--
|
|
15
|
+
-- derivedBy имя проектора (план 1.1, §2.4): строки класса пишет только он; наследуется, потомок не меняет
|
|
16
|
+
-- aggregate { <поле>: { sum: <поле источника> | count: true, of: <класс-источник>, via: <роль>, where?: JSON Schema } }
|
|
17
|
+
-- агрегаты в базе (план 1.1, §2.4): поле держит триггер по живым строкам источника; наследуется,
|
|
18
|
+
-- потомок заменяет целиком
|
|
19
|
+
-- resolved развёрнутое описание с учётом предков — пишет триггер, присланное значение перезаписывается; у
|
|
20
|
+
-- источника агрегатов в нём ещё метка aggregatedBy — какие цели считают его строки (88-derived.sql)
|
|
16
21
|
-- Скомпилированная проверка данных класса лежит в колонке validator строки Class.
|
|
17
22
|
|
|
18
23
|
create or replace function @SCHEMA@.class_metaschema() returns jsonb
|
|
@@ -69,6 +74,23 @@ return $json${
|
|
|
69
74
|
"ownerDefault": {"type": "string", "enum": ["tenant", "actor"]},
|
|
70
75
|
"cache": {"type": "object"},
|
|
71
76
|
"meta": {"type": "object"},
|
|
77
|
+
"derivedBy": {"type": "string", "pattern": "^[a-z0-9][a-z0-9_.:-]{0,63}$"},
|
|
78
|
+
"aggregate": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"propertyNames": {"type": "string", "pattern": "^[^$]"},
|
|
81
|
+
"additionalProperties": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"required": ["of", "via"],
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"properties": {
|
|
86
|
+
"sum": {"type": "string", "pattern": "^[^$]"},
|
|
87
|
+
"count": {"type": "boolean", "enum": [true]},
|
|
88
|
+
"of": {"type": "string", "pattern": "^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$"},
|
|
89
|
+
"via": {"type": "string", "pattern": "^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]{0,62}$"},
|
|
90
|
+
"where": {"type": "object"}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
72
94
|
"resolved": {"type": "object"}
|
|
73
95
|
}
|
|
74
96
|
}$json$::jsonb;
|
|
@@ -114,6 +136,8 @@ declare
|
|
|
114
136
|
hist jsonb;
|
|
115
137
|
od text;
|
|
116
138
|
cache jsonb;
|
|
139
|
+
der text;
|
|
140
|
+
agg jsonb;
|
|
117
141
|
comp jsonb := '{}';
|
|
118
142
|
i int;
|
|
119
143
|
v jsonb;
|
|
@@ -143,6 +167,16 @@ begin
|
|
|
143
167
|
if v ? 'history' then hist := v->'history'; end if;
|
|
144
168
|
if v ? 'ownerDefault' then od := v->>'ownerDefault'; end if;
|
|
145
169
|
if v ? 'cache' then cache := v->'cache'; end if;
|
|
170
|
+
-- производный класс (план 1.1, §2.4): проектор задаёт предок, потомок его не меняет
|
|
171
|
+
if v ? 'derivedBy' then
|
|
172
|
+
if der is not null and der <> v->>'derivedBy' then
|
|
173
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
|
|
174
|
+
format('derivedBy класса %s (%s) не совпадает с предком (%s)', v->>'name', v->>'derivedBy', der));
|
|
175
|
+
end if;
|
|
176
|
+
der := v->>'derivedBy';
|
|
177
|
+
end if;
|
|
178
|
+
-- агрегаты (план 1.1, §2.4): потомок заменяет описание предка целиком, {} — снимает унаследованное
|
|
179
|
+
if v ? 'aggregate' then agg := v->'aggregate'; end if;
|
|
146
180
|
for m in select * from jsonb_each(coalesce(v->'meta', '{}')) loop
|
|
147
181
|
if m.value = 'null'::jsonb then meta := meta - m.key; else meta := meta || jsonb_build_object(m.key, m.value); end if;
|
|
148
182
|
end loop;
|
|
@@ -158,7 +192,9 @@ begin
|
|
|
158
192
|
'history', coalesce(hist, 'true'),
|
|
159
193
|
'ownerDefault', coalesce(od, 'tenant'),
|
|
160
194
|
'cache', cache,
|
|
161
|
-
|
|
195
|
+
-- ключи только у производных и у целей агрегатов: у описаний до 1.2 их нет, и сравнение «было — стало» их не задевает
|
|
196
|
+
'meta', meta) || case when der is not null then jsonb_build_object('derivedBy', der) else '{}'::jsonb end
|
|
197
|
+
|| case when agg is not null and agg <> '{}'::jsonb then jsonb_build_object('aggregate', agg) else '{}'::jsonb end;
|
|
162
198
|
end $$;
|
|
163
199
|
|
|
164
200
|
-- Проверка описания класса: метасхема, правила ключа (§2.3), правило unset (§2.10), размер meta.
|
|
@@ -243,6 +279,25 @@ begin
|
|
|
243
279
|
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('history: неизвестный часовой пояс %s', res->'history'->>'tz'));
|
|
244
280
|
end if;
|
|
245
281
|
end if;
|
|
282
|
+
-- производный класс (план 1.1, §2.4): системным не бывает; его строки проектор удаляет и строит заново,
|
|
283
|
+
-- поэтому они не мешают удалять строки, на которые ссылаются (onDelete только cascade или unset)
|
|
284
|
+
if res ? 'derivedBy' then
|
|
285
|
+
if coalesce((data->>'system')::boolean, false) then
|
|
286
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', 'системный класс не бывает производным');
|
|
287
|
+
end if;
|
|
288
|
+
for e in select key, value from jsonb_each(res->'ends') loop
|
|
289
|
+
if coalesce(e.value->>'onDelete', 'restrict') not in ('cascade', 'unset') then
|
|
290
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
|
|
291
|
+
format('конец %s производного класса — onDelete cascade или unset: производная строка не мешает удалять цель', e.key));
|
|
292
|
+
end if;
|
|
293
|
+
end loop;
|
|
294
|
+
end if;
|
|
295
|
+
-- агрегаты (план 1.1, §2.4; 88-derived.sql): цель — поля, источники, цикл, условия where; источник — метка
|
|
296
|
+
-- aggregatedBy и та же сверка против агрегатов, которые считают его строки (источник часто описывают после цели)
|
|
297
|
+
if res ? 'aggregate' then
|
|
298
|
+
res := @SCHEMA@.agg_prepare(res, data, p_tenant);
|
|
299
|
+
end if;
|
|
300
|
+
res := @SCHEMA@.agg_source(res, p_tenant);
|
|
246
301
|
select * into c from @SCHEMA@.compile_validator(res->'schema');
|
|
247
302
|
data := data || jsonb_build_object('resolved', res || jsonb_build_object('rules', c.rules, 'vars', c.vars));
|
|
248
303
|
validator := c.validator;
|