letopis 1.0.1 → 1.2.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 +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- 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 +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- 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 +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- 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 +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
package/dist/admin.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* сами сверяют права (letopis.* — системный администратор или администраторское подключение).
|
|
5
5
|
*/
|
|
6
6
|
import type postgres from 'postgres';
|
|
7
|
-
import type { Registry } from './registry.js';
|
|
7
|
+
import type { AggregateInfo, Registry } from './registry.js';
|
|
8
8
|
import { type Executor, type TxContext } from './tx.js';
|
|
9
9
|
type Sql = postgres.Sql<Record<string, unknown>>;
|
|
10
10
|
export interface VerifyIssue {
|
|
@@ -29,7 +29,7 @@ export interface MaintainOptions {
|
|
|
29
29
|
limit?: number;
|
|
30
30
|
}
|
|
31
31
|
export interface MaintainReport {
|
|
32
|
-
/** Другой процесс уже обслуживает установку (
|
|
32
|
+
/** Другой процесс уже обслуживает установку (держит строку-замок обслуживания): этот вызов ничего не делал. */
|
|
33
33
|
skipped: boolean;
|
|
34
34
|
/** Удалено истёкших и погашенных сессий и одноразовых кодов. */
|
|
35
35
|
sessions: number;
|
|
@@ -72,6 +72,10 @@ export interface ModelDescription {
|
|
|
72
72
|
system: boolean;
|
|
73
73
|
fields: Record<string, unknown>;
|
|
74
74
|
required: string[];
|
|
75
|
+
/** Условия по нескольким полям (`fields.anyOf` описания с учётом наследования): хотя бы одно должно выполняться. */
|
|
76
|
+
anyOf?: unknown[];
|
|
77
|
+
/** Условия по нескольким полям (`fields.oneOf`): ровно одно должно выполняться. */
|
|
78
|
+
oneOf?: unknown[];
|
|
75
79
|
ends: Record<string, unknown>;
|
|
76
80
|
key: string[];
|
|
77
81
|
history: unknown;
|
|
@@ -80,6 +84,10 @@ export interface ModelDescription {
|
|
|
80
84
|
ttl?: number;
|
|
81
85
|
};
|
|
82
86
|
meta: Record<string, unknown>;
|
|
87
|
+
/** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
|
|
88
|
+
derivedBy?: string;
|
|
89
|
+
/** Агрегатные поля: сумма или число живых строк класса-источника, их считает база (план 1.1, §2.4). */
|
|
90
|
+
aggregate?: Record<string, AggregateInfo>;
|
|
83
91
|
}[];
|
|
84
92
|
methods: {
|
|
85
93
|
chain: readonly string[];
|
|
@@ -102,8 +110,9 @@ export declare function verify(a: AdminCtx, opts?: {
|
|
|
102
110
|
/**
|
|
103
111
|
* Обслуживание: истёкшие сессии и коды, прореживание журнала пачками (каждая — своя транзакция,
|
|
104
112
|
* долгих блокировок нет), заполнение очереди сигналов. Из нескольких процессов работает один: пачка
|
|
105
|
-
* первым делом берёт
|
|
106
|
-
*
|
|
113
|
+
* первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
|
|
114
|
+
* secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
|
|
115
|
+
* за пулом в режиме транзакций (PgBouncer) оставалась бы на
|
|
107
116
|
* серверном соединении, которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы
|
|
108
117
|
* (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
|
|
109
118
|
* вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
|
|
@@ -131,6 +140,17 @@ export declare function trimHistory(a: AdminCtx, target: string | string[] | {
|
|
|
131
140
|
}[], before: string | Date): Promise<number>;
|
|
132
141
|
/** Новый ключ класса (этап 6, п. 7): строки семейства переносятся на новые id. Возвращает их число. */
|
|
133
142
|
export declare function rekeyClass(a: AdminCtx, cls: string, key: string[]): Promise<number>;
|
|
143
|
+
/**
|
|
144
|
+
* Ремонт агрегатов (план 1.1, §2.4): поля класса и его потомков во всех арендаторах — заново из живых источников,
|
|
145
|
+
* под исключительными блокировками семейств целей и источников (запись в них ждёт). Версия пишется только там, где
|
|
146
|
+
* значение изменилось; возвращает число исправленных строк. Функция владельца — от его роли (set local role), как
|
|
147
|
+
* загрузчик; подключение проверил вызывающий (admin_required).
|
|
148
|
+
*/
|
|
149
|
+
export declare function rebuildAggregates(env: {
|
|
150
|
+
sql: Sql;
|
|
151
|
+
S: string;
|
|
152
|
+
ownerRole: string;
|
|
153
|
+
}, cls: string): Promise<number>;
|
|
134
154
|
/** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
|
|
135
155
|
export declare function describe(a: AdminCtx): ModelDescription;
|
|
136
156
|
/** Описание модели текстом для языковых моделей (llms.txt). */
|
package/dist/admin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LetopisError } from './errors.js';
|
|
1
|
+
import { LetopisError, fromDbError } from './errors.js';
|
|
2
2
|
import { runTx } from './tx.js';
|
|
3
3
|
import { CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
4
4
|
const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
|
|
@@ -7,13 +7,12 @@ export async function verify(a, opts = {}) {
|
|
|
7
7
|
const r = await one(a, `select ${a.S}.verify($1::boolean) as v`, [opts.data === true]);
|
|
8
8
|
return r.v;
|
|
9
9
|
}
|
|
10
|
-
/** Ключ advisory-блокировки обслуживания: форма с двумя int4, своё пространство (19541). */
|
|
11
|
-
const MAINTAIN_LOCK = (schema) => [19541, schema];
|
|
12
10
|
/**
|
|
13
11
|
* Обслуживание: истёкшие сессии и коды, прореживание журнала пачками (каждая — своя транзакция,
|
|
14
12
|
* долгих блокировок нет), заполнение очереди сигналов. Из нескольких процессов работает один: пачка
|
|
15
|
-
* первым делом берёт
|
|
16
|
-
*
|
|
13
|
+
* первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
|
|
14
|
+
* secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
|
|
15
|
+
* за пулом в режиме транзакций (PgBouncer) оставалась бы на
|
|
17
16
|
* серверном соединении, которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы
|
|
18
17
|
* (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
|
|
19
18
|
* вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
|
|
@@ -23,12 +22,10 @@ export async function maintain(a, opts = {}) {
|
|
|
23
22
|
throw new LetopisError('invalid_query', 'maintain() — пачками в своих транзакциях, не внутри db.begin()');
|
|
24
23
|
const now = opts.now === undefined ? null : opts.now instanceof Date ? opts.now.toISOString() : opts.now;
|
|
25
24
|
const report = { skipped: false, sessions: 0, codes: 0, thinned: 0, batches: 0, more: false, queue: 0 };
|
|
26
|
-
const [k1, k2] = MAINTAIN_LOCK(a.schema);
|
|
27
25
|
const max = opts.batches ?? 100;
|
|
28
26
|
for (let i = 0; i < max; i++) {
|
|
29
|
-
//
|
|
30
|
-
const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select
|
|
31
|
-
then ${a.S}.maintain($3::text::timestamptz, $4::int) end as r`, [k1, k2, now, opts.limit ?? 10_000]));
|
|
27
|
+
// блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null
|
|
28
|
+
const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`, [now, opts.limit ?? 10_000]));
|
|
32
29
|
if (r === null) {
|
|
33
30
|
if (i === 0)
|
|
34
31
|
return { ...report, skipped: true };
|
|
@@ -122,6 +119,25 @@ export async function rekeyClass(a, cls, key) {
|
|
|
122
119
|
const r = await one(a, `select ${a.S}.rekey_class($1, $2::jsonb) as n`, [name, key]);
|
|
123
120
|
return r.n;
|
|
124
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Ремонт агрегатов (план 1.1, §2.4): поля класса и его потомков во всех арендаторах — заново из живых источников,
|
|
124
|
+
* под исключительными блокировками семейств целей и источников (запись в них ждёт). Версия пишется только там, где
|
|
125
|
+
* значение изменилось; возвращает число исправленных строк. Функция владельца — от его роли (set local role), как
|
|
126
|
+
* загрузчик; подключение проверил вызывающий (admin_required).
|
|
127
|
+
*/
|
|
128
|
+
export async function rebuildAggregates(env, cls) {
|
|
129
|
+
if (typeof cls !== 'string' || !cls)
|
|
130
|
+
throw new LetopisError('invalid_data', 'rebuildAggregates(класс): имя класса');
|
|
131
|
+
const role = `"${env.ownerRole.replace(/"/g, '""')}"`;
|
|
132
|
+
return env.sql.begin(async (q) => {
|
|
133
|
+
await q.unsafe(`set local role ${role}`);
|
|
134
|
+
await q.unsafe(`select set_config('letopis.reason', 'rebuildAggregates', true)`);
|
|
135
|
+
const [{ n }] = await q.unsafe(`select ${env.S}.rebuild_aggregates($1) as n`, [cls]);
|
|
136
|
+
return n;
|
|
137
|
+
}).catch((e) => {
|
|
138
|
+
throw fromDbError(e);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
125
141
|
/** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
|
|
126
142
|
export function describe(a) {
|
|
127
143
|
const classes = a.registry().all().map((c) => ({
|
|
@@ -134,12 +150,16 @@ export function describe(a) {
|
|
|
134
150
|
system: SYSTEM_CLASSES.has(c.name),
|
|
135
151
|
fields: (c.schema.properties ?? {}),
|
|
136
152
|
required: (c.schema.required ?? []),
|
|
153
|
+
...(Array.isArray(c.schema.anyOf) ? { anyOf: c.schema.anyOf } : {}),
|
|
154
|
+
...(Array.isArray(c.schema.oneOf) ? { oneOf: c.schema.oneOf } : {}),
|
|
137
155
|
ends: c.ends,
|
|
138
156
|
key: c.key,
|
|
139
157
|
history: c.history,
|
|
140
158
|
ownerDefault: c.ownerDefault,
|
|
141
159
|
...(c.cache ? { cache: c.cache } : {}),
|
|
142
160
|
meta: c.meta,
|
|
161
|
+
...(c.derivedBy ? { derivedBy: c.derivedBy } : {}),
|
|
162
|
+
...(c.aggregate ? { aggregate: c.aggregate } : {}),
|
|
143
163
|
})).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
|
|
144
164
|
return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
|
|
145
165
|
}
|
|
@@ -158,12 +178,22 @@ export function describeText(d) {
|
|
|
158
178
|
});
|
|
159
179
|
if (fields.length)
|
|
160
180
|
out.push(`- поля: ${fields.join(', ')}`);
|
|
181
|
+
if (c.anyOf)
|
|
182
|
+
out.push(`- условия anyOf (хотя бы одно): ${JSON.stringify(c.anyOf)}`);
|
|
183
|
+
if (c.oneOf)
|
|
184
|
+
out.push(`- условия oneOf (ровно одно): ${JSON.stringify(c.oneOf)}`);
|
|
161
185
|
const ends = Object.entries(c.ends).map(([k, v]) => {
|
|
162
186
|
const e = v;
|
|
163
187
|
return `${k}${e.required ? '*' : ''} → ${Array.isArray(e.class) ? e.class.join('|') : e.class}${e.many ? '[]' : ''}`;
|
|
164
188
|
});
|
|
165
189
|
if (ends.length)
|
|
166
190
|
out.push(`- концы: ${ends.join(', ')}`);
|
|
191
|
+
if (c.derivedBy)
|
|
192
|
+
out.push(`- производный: строки пишет проектор ${c.derivedBy}, приложение только читает`);
|
|
193
|
+
for (const [field, a] of Object.entries(c.aggregate ?? {})) {
|
|
194
|
+
const what = a.sum !== undefined ? `сумма ${a.sum}` : 'число';
|
|
195
|
+
out.push(`- агрегат ${field} = ${what} строк ${a.of} по концу ${a.via}${a.where ? `, условие ${JSON.stringify(a.where)}` : ''}; поле считает база, приложение его не пишет`);
|
|
196
|
+
}
|
|
167
197
|
out.push('');
|
|
168
198
|
}
|
|
169
199
|
return out.join('\n');
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Signals, type Signal } from './signals.js';
|
|
1
2
|
/** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
|
|
2
3
|
export declare const ACL_CLASSES: ReadonlySet<string>;
|
|
3
4
|
export interface CacheOptions {
|
|
@@ -25,14 +26,22 @@ export interface CacheStats {
|
|
|
25
26
|
/** Снимок счётчиков до запроса: ответ кладётся, только если они не изменились. */
|
|
26
27
|
export interface CacheToken {
|
|
27
28
|
epoch: number;
|
|
28
|
-
|
|
29
|
+
/** Класс, его счётчик и счётчик пары «класс, арендатор» (без арендатора — сигналов класса в любом). */
|
|
30
|
+
classes: [string, number, number][];
|
|
29
31
|
session: number;
|
|
32
|
+
/** Токен арендатора запроса; null — ответ сбрасывается сигналом класса из любого арендатора. */
|
|
33
|
+
tenant: string | null;
|
|
30
34
|
}
|
|
31
35
|
/** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
|
|
32
36
|
export declare const sessionTag: (token: string | null) => string;
|
|
33
37
|
export declare class ResultCache {
|
|
38
|
+
private sig;
|
|
39
|
+
private allTenants;
|
|
34
40
|
private map;
|
|
35
41
|
private classEpoch;
|
|
42
|
+
/** Сигналы класса в арендаторе: по паре и по классу в любом арендаторе (для ответов без арендатора). */
|
|
43
|
+
private pairEpoch;
|
|
44
|
+
private anyEpoch;
|
|
36
45
|
private sessionEpoch;
|
|
37
46
|
private epoch;
|
|
38
47
|
private listening;
|
|
@@ -40,7 +49,11 @@ export declare class ResultCache {
|
|
|
40
49
|
readonly ttlMs: number;
|
|
41
50
|
readonly ttlOnly: boolean;
|
|
42
51
|
private max;
|
|
43
|
-
|
|
52
|
+
/**
|
|
53
|
+
* sig — режим сигналов и ключ подключения (разбор нагрузки, токены арендаторов); allTenants —
|
|
54
|
+
* подключение видит строки всех арендаторов (обходит RLS): ответ сбрасывается сигналом из любого.
|
|
55
|
+
*/
|
|
56
|
+
constructor(opts?: CacheOptions, sig?: Signals, allTenants?: boolean);
|
|
44
57
|
/** Кэш работает: слушатель на месте или режим только по сроку. */
|
|
45
58
|
get enabled(): boolean;
|
|
46
59
|
/** Ключ ответа: запрос, параметры и тот, кто спрашивает. */
|
|
@@ -49,24 +62,35 @@ export declare class ResultCache {
|
|
|
49
62
|
account?: string;
|
|
50
63
|
tenant?: string | null;
|
|
51
64
|
}): string;
|
|
52
|
-
/** Снимок счётчиков
|
|
53
|
-
token(classes: readonly string[], session: string): CacheToken;
|
|
65
|
+
/** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
|
|
66
|
+
token(classes: readonly string[], session: string, tenantId?: string | null): CacheToken;
|
|
67
|
+
private pairCount;
|
|
54
68
|
get(key: string): unknown[] | undefined;
|
|
55
69
|
/** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
|
|
56
70
|
set(key: string, value: unknown[], classes: readonly string[], session: string, tok: CacheToken, ttlMs: number, sessionExpires: number | null): boolean;
|
|
57
|
-
/** Сбросить ответы, читавшие эти
|
|
71
|
+
/** Сбросить ответы, читавшие эти классы (во всех арендаторах). */
|
|
58
72
|
invalidate(classes: Iterable<string>): void;
|
|
73
|
+
/** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
|
|
74
|
+
private invalidateIn;
|
|
59
75
|
/** Сбросить ответы сессии (тег — начало sha256 токена). */
|
|
60
76
|
invalidateSession(tag: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
|
|
79
|
+
* может прислать любая роль базы: при переполнении карта очищается, и снимки в полёте устаревают целиком
|
|
80
|
+
* (epoch), — иначе заливка разными именами копила бы счётчики без предела (ревью №1 точки А плана 1.1).
|
|
81
|
+
*/
|
|
82
|
+
private bump;
|
|
61
83
|
/** Весь кэш. */
|
|
62
84
|
clear(): void;
|
|
85
|
+
/** Сброс по разобранному сигналу. */
|
|
86
|
+
apply(s: Signal): void;
|
|
63
87
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
88
|
+
* Нагрузки из letopis.changed своей транзакции (те же, что уйдут сигналом). raw — запись через db.sql:
|
|
89
|
+
* весь кэш.
|
|
66
90
|
*/
|
|
67
91
|
changed(names: Iterable<string>, raw?: boolean): void;
|
|
68
|
-
/** Сигнал от
|
|
69
|
-
signal(
|
|
92
|
+
/** Сигнал от базы: нагрузка или уже разобранный. */
|
|
93
|
+
signal(s: string | Signal): void;
|
|
70
94
|
/** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
|
|
71
95
|
listen(): void;
|
|
72
96
|
stats(): CacheStats;
|
package/dist/cache.js
CHANGED
|
@@ -6,23 +6,34 @@
|
|
|
6
6
|
* запрос разным пользователям отдаёт разные строки. Сброс:
|
|
7
7
|
* - свои записи — сразу после фиксации, по классам из letopis.changed (туда попадают и классы
|
|
8
8
|
* каскада, unset, смены класса и ключа); запись через db.sql — весь кэш;
|
|
9
|
-
* - чужие записи — по сигналу базы
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
|
|
10
|
+
* сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
|
|
11
|
+
* opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) и «*» —
|
|
12
|
+
* весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
|
|
13
|
+
* - гонка запроса и сигнала: счётчики сигналов по классам, парам «класс, арендатор» и сессиям
|
|
14
|
+
* снимаются до запроса, ответ кладётся, только если они не изменились;
|
|
13
15
|
* - (пере)подключение слушателя сигналов — весь кэш: сигналы за время обрыва потеряны; без слушателя
|
|
14
16
|
* кэш выключен (с ttlOnly — работает только по сроку);
|
|
15
17
|
* - срок (по умолчанию 60 с) — предохранитель; ответы истёкшей сессии не используются.
|
|
16
18
|
* Внутри db.begin() и в db.sql кэш не работает.
|
|
17
19
|
*/
|
|
18
20
|
import { createHash } from 'node:crypto';
|
|
21
|
+
import { ACL_CLASS_NAMES } from './types.js';
|
|
22
|
+
import { Signals } from './signals.js';
|
|
19
23
|
/** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
|
|
20
|
-
export const ACL_CLASSES = new Set(
|
|
24
|
+
export const ACL_CLASSES = new Set(ACL_CLASS_NAMES);
|
|
25
|
+
/** Счётчиков сигналов в каждой карте не больше: имена в нагрузке присылает любая роль базы. */
|
|
26
|
+
const EPOCH_MAX = 100_000;
|
|
21
27
|
/** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
|
|
22
28
|
export const sessionTag = (token) => token ? createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16) : '';
|
|
23
29
|
export class ResultCache {
|
|
30
|
+
sig;
|
|
31
|
+
allTenants;
|
|
24
32
|
map = new Map();
|
|
25
33
|
classEpoch = new Map();
|
|
34
|
+
/** Сигналы класса в арендаторе: по паре и по классу в любом арендаторе (для ответов без арендатора). */
|
|
35
|
+
pairEpoch = new Map();
|
|
36
|
+
anyEpoch = new Map();
|
|
26
37
|
sessionEpoch = new Map();
|
|
27
38
|
epoch = 0;
|
|
28
39
|
listening = false;
|
|
@@ -30,7 +41,13 @@ export class ResultCache {
|
|
|
30
41
|
ttlMs;
|
|
31
42
|
ttlOnly;
|
|
32
43
|
max;
|
|
33
|
-
|
|
44
|
+
/**
|
|
45
|
+
* sig — режим сигналов и ключ подключения (разбор нагрузки, токены арендаторов); allTenants —
|
|
46
|
+
* подключение видит строки всех арендаторов (обходит RLS): ответ сбрасывается сигналом из любого.
|
|
47
|
+
*/
|
|
48
|
+
constructor(opts = {}, sig = new Signals(), allTenants = false) {
|
|
49
|
+
this.sig = sig;
|
|
50
|
+
this.allTenants = allTenants;
|
|
34
51
|
this.ttlMs = (opts.ttl ?? 60) * 1000;
|
|
35
52
|
this.ttlOnly = opts.ttlOnly === true;
|
|
36
53
|
this.max = opts.max ?? 10_000;
|
|
@@ -43,14 +60,19 @@ export class ResultCache {
|
|
|
43
60
|
key(text, params, who) {
|
|
44
61
|
return JSON.stringify([text, params, who.token ?? '', who.account ?? '', who.tenant ?? '']);
|
|
45
62
|
}
|
|
46
|
-
/** Снимок счётчиков
|
|
47
|
-
token(classes, session) {
|
|
63
|
+
/** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
|
|
64
|
+
token(classes, session, tenantId) {
|
|
65
|
+
const tenant = this.allTenants ? null : this.sig.tenantToken(tenantId);
|
|
48
66
|
return {
|
|
49
67
|
epoch: this.epoch,
|
|
50
|
-
classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0]),
|
|
68
|
+
classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0, this.pairCount(c, tenant)]),
|
|
51
69
|
session: this.sessionEpoch.get(session) ?? 0,
|
|
70
|
+
tenant,
|
|
52
71
|
};
|
|
53
72
|
}
|
|
73
|
+
pairCount(cls, tenant) {
|
|
74
|
+
return (tenant === null ? this.anyEpoch.get(cls) : this.pairEpoch.get(`${cls}@${tenant}`)) ?? 0;
|
|
75
|
+
}
|
|
54
76
|
get(key) {
|
|
55
77
|
if (!this.enabled)
|
|
56
78
|
return undefined;
|
|
@@ -71,70 +93,97 @@ export class ResultCache {
|
|
|
71
93
|
return false;
|
|
72
94
|
const stale = tok.epoch !== this.epoch
|
|
73
95
|
|| tok.session !== (this.sessionEpoch.get(session) ?? 0)
|
|
74
|
-
|| tok.classes.some(([c, n]) => (this.classEpoch.get(c) ?? 0) !== n);
|
|
96
|
+
|| tok.classes.some(([c, n, m]) => (this.classEpoch.get(c) ?? 0) !== n || this.pairCount(c, tok.tenant) !== m);
|
|
75
97
|
if (stale) {
|
|
76
98
|
this.st.raced++;
|
|
77
99
|
return false;
|
|
78
100
|
}
|
|
79
101
|
if (this.map.size >= this.max)
|
|
80
102
|
this.map.delete(this.map.keys().next().value);
|
|
81
|
-
this.map.set(key, { value, classes: [...classes], session, expires: Date.now() + ttlMs, sessionExpires });
|
|
103
|
+
this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
|
|
82
104
|
this.st.stores++;
|
|
83
105
|
return true;
|
|
84
106
|
}
|
|
85
|
-
/** Сбросить ответы, читавшие эти
|
|
107
|
+
/** Сбросить ответы, читавшие эти классы (во всех арендаторах). */
|
|
86
108
|
invalidate(classes) {
|
|
87
109
|
const set = new Set(classes);
|
|
88
110
|
if (!set.size)
|
|
89
111
|
return;
|
|
90
112
|
for (const c of set)
|
|
91
|
-
this.
|
|
113
|
+
this.bump(this.classEpoch, c);
|
|
92
114
|
for (const [k, e] of this.map)
|
|
93
115
|
if (e.classes.some((c) => set.has(c)))
|
|
94
116
|
this.map.delete(k);
|
|
95
117
|
}
|
|
118
|
+
/** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
|
|
119
|
+
invalidateIn(cls, tenant) {
|
|
120
|
+
if (tenant === null)
|
|
121
|
+
return this.invalidate([cls]);
|
|
122
|
+
this.bump(this.pairEpoch, `${cls}@${tenant}`);
|
|
123
|
+
this.bump(this.anyEpoch, cls);
|
|
124
|
+
for (const [key, e] of this.map)
|
|
125
|
+
if ((e.tenant === null || e.tenant === tenant) && e.classes.includes(cls))
|
|
126
|
+
this.map.delete(key);
|
|
127
|
+
}
|
|
96
128
|
/** Сбросить ответы сессии (тег — начало sha256 токена). */
|
|
97
129
|
invalidateSession(tag) {
|
|
98
|
-
this.
|
|
130
|
+
this.bump(this.sessionEpoch, tag);
|
|
99
131
|
for (const [k, e] of this.map)
|
|
100
132
|
if (e.session === tag)
|
|
101
133
|
this.map.delete(k);
|
|
102
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
|
|
137
|
+
* может прислать любая роль базы: при переполнении карта очищается, и снимки в полёте устаревают целиком
|
|
138
|
+
* (epoch), — иначе заливка разными именами копила бы счётчики без предела (ревью №1 точки А плана 1.1).
|
|
139
|
+
*/
|
|
140
|
+
bump(m, k) {
|
|
141
|
+
if (m.size >= EPOCH_MAX && !m.has(k)) {
|
|
142
|
+
m.clear();
|
|
143
|
+
this.epoch++;
|
|
144
|
+
}
|
|
145
|
+
m.set(k, (m.get(k) ?? 0) + 1);
|
|
146
|
+
}
|
|
103
147
|
/** Весь кэш. */
|
|
104
148
|
clear() {
|
|
105
149
|
this.epoch++;
|
|
106
150
|
this.map.clear();
|
|
107
151
|
}
|
|
152
|
+
/** Сброс по разобранному сигналу. */
|
|
153
|
+
apply(s) {
|
|
154
|
+
switch (s.kind) {
|
|
155
|
+
case 'session':
|
|
156
|
+
return this.invalidateSession(s.tag);
|
|
157
|
+
case 'class':
|
|
158
|
+
return ACL_CLASSES.has(s.cls) ? this.clear() : this.invalidateIn(s.cls, s.tenant);
|
|
159
|
+
case 'unknown':
|
|
160
|
+
// класса нет в реестрах процесса — и ответов с ним нет
|
|
161
|
+
return;
|
|
162
|
+
default:
|
|
163
|
+
// *, session:*, opaque без ключа, нагрузка не по режиму
|
|
164
|
+
return this.clear();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
108
167
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
168
|
+
* Нагрузки из letopis.changed своей транзакции (те же, что уйдут сигналом). raw — запись через db.sql:
|
|
169
|
+
* весь кэш.
|
|
111
170
|
*/
|
|
112
171
|
changed(names, raw = false) {
|
|
113
|
-
const classes = [];
|
|
114
172
|
let any = false;
|
|
115
173
|
for (const n of names) {
|
|
116
174
|
if (!n)
|
|
117
175
|
continue;
|
|
118
176
|
any = true;
|
|
119
|
-
if (
|
|
120
|
-
this.
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (n.startsWith('session:'))
|
|
124
|
-
this.invalidateSession(n.slice(8));
|
|
125
|
-
else
|
|
126
|
-
classes.push(n);
|
|
177
|
+
if (!raw)
|
|
178
|
+
this.apply(this.sig.parse(n));
|
|
127
179
|
}
|
|
128
|
-
if (raw && any)
|
|
180
|
+
if (raw && any)
|
|
129
181
|
this.clear();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
this.invalidate(classes);
|
|
133
182
|
}
|
|
134
|
-
/** Сигнал от
|
|
135
|
-
signal(
|
|
183
|
+
/** Сигнал от базы: нагрузка или уже разобранный. */
|
|
184
|
+
signal(s) {
|
|
136
185
|
this.st.signals++;
|
|
137
|
-
this.
|
|
186
|
+
this.apply(typeof s === 'string' ? this.sig.parse(s) : s);
|
|
138
187
|
}
|
|
139
188
|
/** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
|
|
140
189
|
listen() {
|
package/dist/chain.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ResultCache } from './cache.js';
|
|
|
2
2
|
import type { ClassInfo, Registry } from './registry.js';
|
|
3
3
|
import { type Step } from './sql.js';
|
|
4
4
|
import type { Executor } from './tx.js';
|
|
5
|
-
import type { ChainMods, Cursor, Filter, Path, QueryEvent, Row } from './types.js';
|
|
5
|
+
import type { BlameOptions, BlameResult, ChainMods, Cursor, DiffOptions, DiffResult, Filter, Path, QueryEvent, Row } from './types.js';
|
|
6
6
|
export interface ChainCtx {
|
|
7
7
|
readonly reg: Registry;
|
|
8
8
|
readonly schema: string;
|
|
@@ -16,6 +16,9 @@ export interface ChainCtx {
|
|
|
16
16
|
readonly slowMs?: number;
|
|
17
17
|
/** Цепочка из батча: план копится в очереди до batch.run(). */
|
|
18
18
|
readonly batch?: BatchRef;
|
|
19
|
+
/** Причина изменения по умолчанию для глаголов (db.batch(имя, { reason })) и та, что стоит в транзакции (db.begin({ reason })). */
|
|
20
|
+
readonly reason?: string;
|
|
21
|
+
readonly txReason?: string;
|
|
19
22
|
/** Кэш результатов подключения и тот, кто спрашивает (ключ ответа, §2.12). */
|
|
20
23
|
readonly cache?: {
|
|
21
24
|
store: ResultCache;
|
|
@@ -61,6 +64,10 @@ export interface ChainCore {
|
|
|
61
64
|
versions(opts?: {
|
|
62
65
|
follow?: boolean;
|
|
63
66
|
}): Promise<Row[]>;
|
|
67
|
+
/** Что изменилось между двумя версиями или моментами (по умолчанию — предыдущая версия → последняя), план 1.1. */
|
|
68
|
+
diff(opts?: DiffOptions): Promise<DiffResult[]>;
|
|
69
|
+
/** Кто, когда и почему последним изменил каждое поле версии (по умолчанию текущей), план 1.1. */
|
|
70
|
+
blame(opts?: BlameOptions): Promise<BlameResult[]>;
|
|
64
71
|
/** Агрегаты по полю последнего шага ('data.<путь>') — по сущностям, считает база. */
|
|
65
72
|
sum(field: string): Promise<number | null>;
|
|
66
73
|
avg(field: string): Promise<number | null>;
|
|
@@ -92,31 +99,46 @@ export interface ChainCore {
|
|
|
92
99
|
/** Вставить узел: ленивая цепочка-паттерн или строка ответа; та же переменная повторно — возврат к её узлу. */
|
|
93
100
|
entity(x: Chain | Row): Chain;
|
|
94
101
|
/** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
|
|
95
|
-
create(data?: Record<string, unknown
|
|
102
|
+
create(data?: Record<string, unknown>, opts?: {
|
|
103
|
+
reason?: string;
|
|
104
|
+
}): Chain;
|
|
96
105
|
/** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — строгий режим. */
|
|
97
106
|
update(patch?: Record<string, unknown>, opts?: {
|
|
98
107
|
rev?: number;
|
|
108
|
+
reason?: string;
|
|
99
109
|
}): Chain;
|
|
100
110
|
/** Создать или заменить целиком (класс с ключом); строка ответа несёт $upsert. */
|
|
101
|
-
upsert(data: Record<string, unknown
|
|
111
|
+
upsert(data: Record<string, unknown>, opts?: {
|
|
112
|
+
reason?: string;
|
|
113
|
+
}): Chain;
|
|
102
114
|
/** Удалить цели шага с каскадом; без { confirm: true } — превью замыкания ($action). */
|
|
103
115
|
delete(opts?: {
|
|
104
116
|
confirm?: boolean;
|
|
117
|
+
reason?: string;
|
|
105
118
|
}): Chain;
|
|
106
119
|
/** Затереть строковые поля ('[erased]') и пометить тегом anonymized. */
|
|
107
|
-
anonymize(fields: string[]
|
|
120
|
+
anonymize(fields: string[], opts?: {
|
|
121
|
+
reason?: string;
|
|
122
|
+
}): Chain;
|
|
108
123
|
/** Сменить класс: у класса с ключом — новый id и перевод ссылок. */
|
|
109
|
-
reclass(cls: string, data?: Record<string, unknown
|
|
124
|
+
reclass(cls: string, data?: Record<string, unknown>, opts?: {
|
|
125
|
+
reason?: string;
|
|
126
|
+
}): Chain;
|
|
110
127
|
/** Чтение с блокировкой строк последнего шага до конца db.begin() (rows, first, ids). */
|
|
111
128
|
forUpdate(): Chain;
|
|
112
129
|
/** Восстановить удалённые объекты шага: последний снимок новой версией (id прежний). */
|
|
113
|
-
restore(
|
|
130
|
+
restore(opts?: {
|
|
131
|
+
reason?: string;
|
|
132
|
+
}): Chain;
|
|
114
133
|
/** Стереть историю удалённых объектов шага; без { confirm: true } — превью ($versions). */
|
|
115
134
|
purge(opts?: {
|
|
116
135
|
confirm?: boolean;
|
|
136
|
+
reason?: string;
|
|
117
137
|
}): Chain;
|
|
118
138
|
/** Новое значение ключа: строка с новым id, ссылки переведены (поля — данные, роли — концы). */
|
|
119
|
-
rekey(patch: Record<string, unknown
|
|
139
|
+
rekey(patch: Record<string, unknown>, opts?: {
|
|
140
|
+
reason?: string;
|
|
141
|
+
}): Chain;
|
|
120
142
|
/** Кэш результатов (§2.12): ответ хранится в памяти процесса ttl секунд (по умолчанию 60). */
|
|
121
143
|
cache(opts?: {
|
|
122
144
|
ttl?: number;
|