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/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;
|
|
@@ -84,6 +84,10 @@ export interface ModelDescription {
|
|
|
84
84
|
ttl?: number;
|
|
85
85
|
};
|
|
86
86
|
meta: Record<string, unknown>;
|
|
87
|
+
/** Производный класс: строки пишет только проектор с этим именем (план 1.1, §2.4). */
|
|
88
|
+
derivedBy?: string;
|
|
89
|
+
/** Агрегатные поля: сумма или число живых строк класса-источника, их считает база (план 1.1, §2.4). */
|
|
90
|
+
aggregate?: Record<string, AggregateInfo>;
|
|
87
91
|
}[];
|
|
88
92
|
methods: {
|
|
89
93
|
chain: readonly string[];
|
|
@@ -106,8 +110,9 @@ export declare function verify(a: AdminCtx, opts?: {
|
|
|
106
110
|
/**
|
|
107
111
|
* Обслуживание: истёкшие сессии и коды, прореживание журнала пачками (каждая — своя транзакция,
|
|
108
112
|
* долгих блокировок нет), заполнение очереди сигналов. Из нескольких процессов работает один: пачка
|
|
109
|
-
* первым делом берёт
|
|
110
|
-
*
|
|
113
|
+
* первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
|
|
114
|
+
* secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
|
|
115
|
+
* за пулом в режиме транзакций (PgBouncer) оставалась бы на
|
|
111
116
|
* серверном соединении, которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы
|
|
112
117
|
* (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
|
|
113
118
|
* вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
|
|
@@ -135,6 +140,17 @@ export declare function trimHistory(a: AdminCtx, target: string | string[] | {
|
|
|
135
140
|
}[], before: string | Date): Promise<number>;
|
|
136
141
|
/** Новый ключ класса (этап 6, п. 7): строки семейства переносятся на новые id. Возвращает их число. */
|
|
137
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>;
|
|
138
154
|
/** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
|
|
139
155
|
export declare function describe(a: AdminCtx): ModelDescription;
|
|
140
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) => ({
|
|
@@ -142,6 +158,8 @@ export function describe(a) {
|
|
|
142
158
|
ownerDefault: c.ownerDefault,
|
|
143
159
|
...(c.cache ? { cache: c.cache } : {}),
|
|
144
160
|
meta: c.meta,
|
|
161
|
+
...(c.derivedBy ? { derivedBy: c.derivedBy } : {}),
|
|
162
|
+
...(c.aggregate ? { aggregate: c.aggregate } : {}),
|
|
145
163
|
})).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
|
|
146
164
|
return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
|
|
147
165
|
}
|
|
@@ -170,6 +188,12 @@ export function describeText(d) {
|
|
|
170
188
|
});
|
|
171
189
|
if (ends.length)
|
|
172
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
|
+
}
|
|
173
197
|
out.push('');
|
|
174
198
|
}
|
|
175
199
|
return out.join('\n');
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* letopis import --from <строка 0.21> --from-schema v1.x --dsn … --schema v2.x [--tenant <имя>] [--keyless C]…
|
|
14
14
|
* [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] [--initial] — код 1, если verify нашёл расхождения
|
|
15
15
|
* --initial — первая заливка (план 1.1, §2.3): пустая схема, одна транзакция, индексы и триггеры — после загрузки
|
|
16
|
+
* letopis mcp --dsn … --schema v2.x --token … [--write] — сервер MCP по stdio (план 1.1, §2.5); dsn, schema и
|
|
17
|
+
* token — ещё из LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; только токен пользователя: --api-key — отказ
|
|
16
18
|
*/
|
|
17
19
|
import { parseArgs } from 'node:util';
|
|
18
20
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
@@ -30,7 +32,9 @@ letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> |
|
|
|
30
32
|
letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
|
|
31
33
|
letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
|
|
32
34
|
letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
|
|
33
|
-
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
35
|
+
[--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]
|
|
36
|
+
letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
|
|
37
|
+
(или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)`;
|
|
34
38
|
const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
|
|
35
39
|
export async function main(argv) {
|
|
36
40
|
const { values, positionals } = parseArgs({
|
|
@@ -44,10 +48,16 @@ export async function main(argv) {
|
|
|
44
48
|
format: { type: 'string' },
|
|
45
49
|
from: { type: 'string' }, 'from-schema': { type: 'string' }, tenant: { type: 'string' }, partition: { type: 'string' },
|
|
46
50
|
keyless: { type: 'string', multiple: true }, rename: { type: 'string', multiple: true }, update: { type: 'boolean' },
|
|
47
|
-
initial: { type: 'boolean' },
|
|
51
|
+
initial: { type: 'boolean' }, write: { type: 'boolean' },
|
|
48
52
|
},
|
|
49
53
|
});
|
|
50
54
|
const [cmd, arg] = positionals;
|
|
55
|
+
// клиенты MCP передают строку подключения и токен окружением (план 1.1, §2.5) — только для этой команды
|
|
56
|
+
if (cmd === 'mcp') {
|
|
57
|
+
values.dsn ??= process.env.LETOPIS_DSN || undefined;
|
|
58
|
+
values.schema ??= process.env.LETOPIS_SCHEMA || undefined;
|
|
59
|
+
values.token ??= process.env.LETOPIS_TOKEN || undefined;
|
|
60
|
+
}
|
|
51
61
|
if (values.help || !cmd || !values.dsn || !values.schema) {
|
|
52
62
|
console.log(USAGE);
|
|
53
63
|
return cmd ? 2 : 0;
|
|
@@ -75,10 +85,13 @@ export async function main(argv) {
|
|
|
75
85
|
console.log(JSON.stringify(r, null, 2));
|
|
76
86
|
return r.verify.ok ? 0 : 1;
|
|
77
87
|
}
|
|
88
|
+
if (cmd === 'mcp')
|
|
89
|
+
return mcp(values);
|
|
78
90
|
// загрузчику нужно администраторское подключение (§2.6, п. 1)
|
|
79
91
|
const db = await connect({ dsn: values.dsn, schema: values.schema, token: values.token, apiKey: values['api-key'], max: 2,
|
|
80
|
-
// verify и maintain работают и на администраторском подключении без
|
|
81
|
-
|
|
92
|
+
// verify и maintain работают и на администраторском подключении без сессии; производные классы (derivedBy)
|
|
93
|
+
// синхронизирует администраторское подключение с сессией арендатора
|
|
94
|
+
...(cmd === 'load' || cmd === 'verify' || cmd === 'maintain' || cmd === 'sync' ? { allowBypassRls: true } : {}) });
|
|
82
95
|
try {
|
|
83
96
|
if (cmd === 'sync') {
|
|
84
97
|
if (!arg) {
|
|
@@ -157,6 +170,42 @@ export async function main(argv) {
|
|
|
157
170
|
await db.close();
|
|
158
171
|
}
|
|
159
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* letopis mcp (план 1.1, §2.5): сервер MCP по stdio от имени одного пользователя. Только токен сессии пользователя —
|
|
175
|
+
* ключ сервиса даёт все права auth.* (имперсонация, выпуск сессий) и лежал бы на машине агента. Модуль mcp.js и SDK
|
|
176
|
+
* грузятся только здесь: letopis --help и прочие команды работают без SDK. В stdout — только протокол (предупреждения
|
|
177
|
+
* connect() идут в stderr); процесс живёт, пока клиент не закроет stdin.
|
|
178
|
+
*/
|
|
179
|
+
async function mcp(v) {
|
|
180
|
+
if (v['api-key'] !== undefined) {
|
|
181
|
+
console.error('letopis: user_session_required: letopis mcp принимает только токен сессии пользователя (--token или LETOPIS_TOKEN): ключ сервиса даёт все права auth.* и не должен лежать на машине агента');
|
|
182
|
+
return 2;
|
|
183
|
+
}
|
|
184
|
+
if (!v.token) {
|
|
185
|
+
console.error('letopis: user_session_required: letopis mcp — нужен токен сессии пользователя: --token или LETOPIS_TOKEN');
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
const { createMcpServer } = await import('./mcp.js');
|
|
189
|
+
const db = await connect({ dsn: v.dsn, schema: v.schema, token: v.token, max: 4 });
|
|
190
|
+
try {
|
|
191
|
+
const opts = { write: v.write === true };
|
|
192
|
+
// учётные данные — до начала протокола: отказ виден кодом выхода и текстом в stderr
|
|
193
|
+
await createMcpServer(db, opts);
|
|
194
|
+
const { serveStdio } = await import('@modelcontextprotocol/server/stdio');
|
|
195
|
+
const closed = new Promise((resolve) => {
|
|
196
|
+
process.stdin.once('end', resolve);
|
|
197
|
+
process.stdin.once('close', resolve);
|
|
198
|
+
});
|
|
199
|
+
// экземпляр сервера — на соединение (и на пробу server/discover): ревизию протокола выбирает клиент
|
|
200
|
+
const handle = serveStdio(() => createMcpServer(db, opts), { onerror: (e) => console.error(`letopis mcp: ${e.message}`) });
|
|
201
|
+
await closed;
|
|
202
|
+
await handle.close();
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await db.close();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
160
209
|
// Запущен ли файл как программа. Сравниваются реальные пути: на Linux и macOS `node_modules/.bin/letopis`
|
|
161
210
|
// (npx, скрипты package.json) — симлинк, `process.argv[1]` указывает на него, а `import.meta.url` — на сам файл.
|
|
162
211
|
function isMain() {
|
package/dist/errors.js
CHANGED
|
@@ -35,7 +35,8 @@ const LT_NAMES = {
|
|
|
35
35
|
LT006: 'acl_denied', LT007: 'id_mismatch', LT008: 'id_from_db', LT009: 'immutable_key', LT010: 'invalid_class',
|
|
36
36
|
LT011: 'use_reclass', LT012: 'reclass_denied', LT013: 'tightening_conflict', LT014: 'isolation_level',
|
|
37
37
|
LT015: 'recreated_in_statement', LT016: 'cursor_expired', LT017: 'delete_restricted', LT018: 'not_deleted',
|
|
38
|
-
LT019: 'reset_disabled', LT020: 'confirm_mismatch',
|
|
38
|
+
LT019: 'reset_disabled', LT020: 'confirm_mismatch', LT021: 'derived_class', LT022: 'aggregate_field',
|
|
39
|
+
LT023: 'projector_pending',
|
|
39
40
|
};
|
|
40
41
|
/**
|
|
41
42
|
* Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL
|
|
@@ -64,7 +65,8 @@ export function fromDbError(e) {
|
|
|
64
65
|
if (name === 'invalid_data' && Array.isArray(issues)) {
|
|
65
66
|
const cls = /класса (\S+)/.exec(text)?.[1] ?? '?';
|
|
66
67
|
const v = new ValidationError(cls, issues);
|
|
67
|
-
|
|
68
|
+
// текст базы целиком: в нём бывает больше, чем имя класса (у проектора — арендатор и id строки)
|
|
69
|
+
return Object.assign(v, { cause: e, ...(text ? { message: `letopis: invalid_data: ${text}` } : {}) });
|
|
68
70
|
}
|
|
69
71
|
return Object.assign(new LetopisError(name, text, detail), { cause: e });
|
|
70
72
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -17,12 +17,13 @@ import { type Batch, type Chain, type StepFn } from './chain.js';
|
|
|
17
17
|
import type { QueryEvent, Row } from './types.js';
|
|
18
18
|
import { type CacheOptions, type CacheStats } from './cache.js';
|
|
19
19
|
import { Subscription, type WatchOptions } from './watch.js';
|
|
20
|
+
import { Projector, type ProjectorOptions } from './project.js';
|
|
20
21
|
import { type MaintainOptions, type MaintainReport, type ModelDescription, type ResetOptions, type ResetResult, type VerifyResult } from './admin.js';
|
|
21
22
|
import type { AnyModelLike, StepsOf } from './typed.js';
|
|
22
23
|
export { DDL_REVISION } from './types.js';
|
|
23
24
|
export { uuidv5, v5Name, v5Id, idOf, type KeyedClass } from './uuid.js';
|
|
24
25
|
export { LetopisError, ValidationError, fromDbError, type Issue } from './errors.js';
|
|
25
|
-
export { Registry, type ClassInfo, type EndInfo, type LeafType } from './registry.js';
|
|
26
|
+
export { Registry, type AggregateInfo, type ClassInfo, type EndInfo, type LeafType } from './registry.js';
|
|
26
27
|
export { compileValidator, applyDefaults, type Validator } from './validate.js';
|
|
27
28
|
export { syncModels, generateTypes, schemaToTs, type SyncResult } from './sync.js';
|
|
28
29
|
export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, isOp } from './ops.js';
|
|
@@ -38,6 +39,7 @@ export type { DiffChange, DiffResult, DiffOptions, BlameField, BlameResult, Blam
|
|
|
38
39
|
export type { CacheOptions, CacheStats } from './cache.js';
|
|
39
40
|
export type { SignalMode } from './signals.js';
|
|
40
41
|
export type { WatchOptions, WatchEvent, Subscription } from './watch.js';
|
|
42
|
+
export type { Projector, ProjectorOptions, ProjectorChange, ProjectorOut, ProjectorRow, ProjectorReport, ProjectorStatus, ProjectorFailure } from './project.js';
|
|
41
43
|
export type { VerifyResult, VerifyIssue, MaintainOptions, MaintainReport, ResetOptions, ResetResult, ModelDescription } from './admin.js';
|
|
42
44
|
export { describeText } from './admin.js';
|
|
43
45
|
export { up, createTenant, type UpOptions, type UpResult, type HistoryPolicy, type TenantOptions, type TenantResult } from './up.js';
|
|
@@ -81,7 +83,7 @@ export interface ConnectOptions {
|
|
|
81
83
|
listen?: boolean;
|
|
82
84
|
/** Кэш результатов (§2.12): срок по умолчанию, размер, ttlOnly; false — выключен. */
|
|
83
85
|
cache?: CacheOptions | false;
|
|
84
|
-
/** Запускать maintain() по расписанию ('1h', '15m', мс) под
|
|
86
|
+
/** Запускать maintain() по расписанию ('1h', '15m', мс) под блокировкой обслуживания: работает один процесс. */
|
|
85
87
|
maintain?: string | number;
|
|
86
88
|
}
|
|
87
89
|
export interface DbCore {
|
|
@@ -151,6 +153,18 @@ export interface DbCore {
|
|
|
151
153
|
* целям вне загрузки. Только на администраторском подключении (иначе admin_required).
|
|
152
154
|
*/
|
|
153
155
|
load(input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
|
|
156
|
+
/**
|
|
157
|
+
* Проектор из журнала (план 1.1, §2.4): строит строки производных классов (derivedBy: имя) из изменений
|
|
158
|
+
* классов from; run() — догнать журнал и следовать за ним, rebuild() — перестроить с текущего состояния.
|
|
159
|
+
* Только на администраторском подключении (иначе admin_required).
|
|
160
|
+
*/
|
|
161
|
+
projector(name: string, opts: ProjectorOptions): Projector;
|
|
162
|
+
/**
|
|
163
|
+
* Ремонт агрегатов в базе (план 1.1, §2.4): поля класса (с потомками) во всех арендаторах — заново из живых строк
|
|
164
|
+
* источников, под исключительными блокировками; версия — только у исправленных. Возвращает их число. Только на
|
|
165
|
+
* администраторском подключении (иначе admin_required).
|
|
166
|
+
*/
|
|
167
|
+
rebuildAggregates(cls: string): Promise<number>;
|
|
154
168
|
/**
|
|
155
169
|
* Именованная очередь планов фасада: batch.Класс(…).create(…); batch.run() — одной транзакцией. У корня,
|
|
156
170
|
* у каждого db.as() и у каждой db.begin() очереди свои: план исполняется от имени фасада, который его поставил.
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { LetopisError, fromDbError, removed } from './errors.js';
|
|
|
12
12
|
import { Registry } from './registry.js';
|
|
13
13
|
import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
|
|
14
14
|
import { checkReason } from './write.js';
|
|
15
|
+
import { registerDb } from './internal.js';
|
|
15
16
|
import { makeAuth } from './auth.js';
|
|
16
17
|
import { makeAcl } from './acl.js';
|
|
17
18
|
import { idOf as idOfClass } from './uuid.js';
|
|
@@ -20,8 +21,9 @@ import { makeBatch, startEntity, startStep } from './chain.js';
|
|
|
20
21
|
import { DDL_REVISION } from './types.js';
|
|
21
22
|
import { ACL_CLASSES, ResultCache } from './cache.js';
|
|
22
23
|
import { Subscription } from './watch.js';
|
|
24
|
+
import { Projector } from './project.js';
|
|
23
25
|
import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
|
|
24
|
-
import { describe as describeModel, maintain as runMaintain, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
|
|
26
|
+
import { describe as describeModel, maintain as runMaintain, rebuildAggregates as runRebuildAggregates, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
|
|
25
27
|
export { DDL_REVISION } from './types.js';
|
|
26
28
|
export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
|
|
27
29
|
export { LetopisError, ValidationError, fromDbError } from './errors.js';
|
|
@@ -140,12 +142,22 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
140
142
|
}
|
|
141
143
|
// имперсонация и арендатор проверяются базой сразу: ошибка — здесь, а не на первой цепочке
|
|
142
144
|
const scoped = { ...txc, account: id ?? txc.account, tenant: o?.tenant ?? (id === undefined ? txc.tenant : undefined) };
|
|
143
|
-
|
|
145
|
+
let [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
144
146
|
if (!r?.account)
|
|
145
147
|
throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
// контекст сессии между запросами ушёл из арендатора (сменился или кончился): реестр не загружен и не
|
|
149
|
+
// кэширован (хвост ревью безопасности №3) — контекст читается заново, не больше трёх раз
|
|
150
|
+
for (let attempt = 1;; attempt++) {
|
|
151
|
+
const tenant = r.tenant;
|
|
152
|
+
const registry = await sh.registryFor(tenant, scoped);
|
|
153
|
+
if (registry)
|
|
154
|
+
return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
|
|
155
|
+
if (attempt === 3)
|
|
156
|
+
throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
|
|
157
|
+
[r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
|
|
158
|
+
if (!r?.account)
|
|
159
|
+
throw new LetopisError('no_session', 'db.as(): нет действующей сессии');
|
|
160
|
+
}
|
|
149
161
|
},
|
|
150
162
|
begin: async (o) => {
|
|
151
163
|
if (tx)
|
|
@@ -160,6 +172,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
160
172
|
throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
|
|
161
173
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
162
174
|
},
|
|
175
|
+
projector: (name, o) => {
|
|
176
|
+
if (tx)
|
|
177
|
+
throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
|
|
178
|
+
if (!sh.admin)
|
|
179
|
+
throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
180
|
+
return new Projector({
|
|
181
|
+
sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
|
|
182
|
+
...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
|
|
183
|
+
}, name, o);
|
|
184
|
+
},
|
|
185
|
+
rebuildAggregates: async (cls) => {
|
|
186
|
+
if (tx)
|
|
187
|
+
throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
|
|
188
|
+
if (!sh.admin)
|
|
189
|
+
throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
190
|
+
return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
|
|
191
|
+
},
|
|
163
192
|
batch: (name, o) => {
|
|
164
193
|
let q = batches.get(name);
|
|
165
194
|
if (!q)
|
|
@@ -171,7 +200,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
171
200
|
rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
|
|
172
201
|
close: () => (tx ? tx.rollback() : sh.close()),
|
|
173
202
|
};
|
|
174
|
-
|
|
203
|
+
const facade = new Proxy(core, {
|
|
175
204
|
get(t, prop, r) {
|
|
176
205
|
if (typeof prop === 'symbol' || prop in t)
|
|
177
206
|
return Reflect.get(t, prop, r);
|
|
@@ -191,6 +220,9 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
191
220
|
return startStep(chainCtx, prop);
|
|
192
221
|
},
|
|
193
222
|
});
|
|
223
|
+
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
224
|
+
registerDb(facade, { allTenants: sh.allTenants, explicit: Boolean(tx) });
|
|
225
|
+
return facade;
|
|
194
226
|
}
|
|
195
227
|
export async function connect(opts) {
|
|
196
228
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
@@ -263,32 +295,60 @@ export async function connect(opts) {
|
|
|
263
295
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
264
296
|
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
265
297
|
const root = { tenant: null, registry: new Registry() };
|
|
266
|
-
|
|
298
|
+
// классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
|
|
299
|
+
// пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
|
|
300
|
+
const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
|
|
301
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
302
|
+
if (String(t?.t ?? '').toLowerCase() !== tenant.toLowerCase())
|
|
303
|
+
return null;
|
|
304
|
+
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
305
|
+
}, { read: true });
|
|
306
|
+
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора
|
|
267
307
|
const others = new Map();
|
|
308
|
+
const loading = new Map();
|
|
268
309
|
// токены классов (режим opaque) — по именам всех реестров подключения
|
|
269
310
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
270
|
-
|
|
311
|
+
// only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
|
|
312
|
+
const refresh = async (only) => {
|
|
271
313
|
const rows = await runTx(ctx, async (q) => {
|
|
272
314
|
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
273
315
|
root.tenant = t?.t ?? null;
|
|
274
316
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
275
317
|
});
|
|
276
318
|
root.registry.load(rows);
|
|
277
|
-
for (const o of others
|
|
278
|
-
|
|
319
|
+
for (const [tenant, o] of others) {
|
|
320
|
+
if (only && !only.has(sig.tenantToken(tenant) ?? ''))
|
|
321
|
+
continue;
|
|
322
|
+
const loaded = await loadClasses(o.txc, tenant).catch(() => null);
|
|
323
|
+
if (loaded)
|
|
324
|
+
o.registry.load(loaded);
|
|
325
|
+
}
|
|
279
326
|
tokens();
|
|
280
327
|
};
|
|
328
|
+
// фасад db.as() не делит реестр с корнем, даже в том же арендаторе: корень после auth.switch() перечитывает свой
|
|
329
|
+
// реестр классами нового арендатора (хвост ревью безопасности №3)
|
|
281
330
|
const registryFor = async (tenant, txc) => {
|
|
282
|
-
if (tenant === root.tenant)
|
|
283
|
-
return root.registry;
|
|
284
331
|
const known = others.get(tenant);
|
|
285
|
-
if (known)
|
|
332
|
+
if (known) {
|
|
333
|
+
known.txc = txc;
|
|
286
334
|
return known.registry;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
335
|
+
}
|
|
336
|
+
// одновременные as() нового арендатора получают один реестр
|
|
337
|
+
let pending = loading.get(tenant);
|
|
338
|
+
if (!pending) {
|
|
339
|
+
pending = (async () => {
|
|
340
|
+
const rows = await loadClasses(txc, tenant);
|
|
341
|
+
if (!rows)
|
|
342
|
+
return null;
|
|
343
|
+
const registry = new Registry();
|
|
344
|
+
registry.load(rows);
|
|
345
|
+
others.set(tenant, { registry, txc });
|
|
346
|
+
tokens();
|
|
347
|
+
return registry;
|
|
348
|
+
})().finally(() => loading.delete(tenant));
|
|
349
|
+
loading.set(tenant, pending);
|
|
350
|
+
}
|
|
351
|
+
return pending;
|
|
292
352
|
};
|
|
293
353
|
// ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
|
|
294
354
|
// работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
|
|
@@ -311,7 +371,7 @@ export async function connect(opts) {
|
|
|
311
371
|
// поддельными сигналами) не становится потоком перечитываний
|
|
312
372
|
const resync = throttle(() => {
|
|
313
373
|
passes.clear();
|
|
314
|
-
void fetchKey().then(refresh).catch(() => { });
|
|
374
|
+
void fetchKey().then(() => refresh()).catch(() => { });
|
|
315
375
|
}, 5000);
|
|
316
376
|
// сигнал Class — реестр сразу, как в 1.0: правка описания доходит без задержки; пока идёт перечитывание, новые
|
|
317
377
|
// сигналы сливаются в одно следующее. Три перечитывания подряд — сразу, дальше не чаще раза в 5 с: заливка
|
|
@@ -319,17 +379,28 @@ export async function connect(opts) {
|
|
|
319
379
|
// точки А)
|
|
320
380
|
let reloading = false;
|
|
321
381
|
let again = false;
|
|
382
|
+
// какие реестры db.as() перечитать: токены арендаторов из сигналов или все (сигнал без арендатора)
|
|
383
|
+
let reloadAll = false;
|
|
384
|
+
const reloadTokens = new Set();
|
|
322
385
|
const startReload = bucket(() => {
|
|
323
386
|
reloading = true;
|
|
324
|
-
|
|
387
|
+
const only = reloadAll ? null : new Set(reloadTokens);
|
|
388
|
+
reloadAll = false;
|
|
389
|
+
reloadTokens.clear();
|
|
390
|
+
void refresh(only).catch(() => { }).finally(() => {
|
|
325
391
|
reloading = false;
|
|
326
392
|
if (again) {
|
|
327
393
|
again = false;
|
|
328
|
-
reload();
|
|
394
|
+
reload(undefined);
|
|
329
395
|
}
|
|
330
396
|
});
|
|
331
397
|
}, 3, 5000);
|
|
332
|
-
|
|
398
|
+
// tenant: null — все реестры, строка — токен арендатора сигнала, undefined — то, что уже накоплено
|
|
399
|
+
const reload = (tenant) => {
|
|
400
|
+
if (tenant === null)
|
|
401
|
+
reloadAll = true;
|
|
402
|
+
else if (tenant !== undefined)
|
|
403
|
+
reloadTokens.add(tenant);
|
|
333
404
|
if (reloading) {
|
|
334
405
|
again = true;
|
|
335
406
|
return;
|
|
@@ -343,7 +414,7 @@ export async function connect(opts) {
|
|
|
343
414
|
if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
|
|
344
415
|
passes.clear();
|
|
345
416
|
if (s.kind === 'class' && s.cls === 'Class')
|
|
346
|
-
reload();
|
|
417
|
+
reload(s.tenant);
|
|
347
418
|
else if (s.kind === 'all' || s.kind === 'opaque' || s.kind === 'unknown' || s.kind === 'foreign')
|
|
348
419
|
resync();
|
|
349
420
|
cache?.signal(s);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Внутренний канал между модулями пакета (план 1.1, этап 5): свойства подключения, которых нет в публичном API.
|
|
3
|
+
* Модуля нет в exports package.json — снаружи пакета его не импортировать; letopis/mcp узнаёт по нему, что
|
|
4
|
+
* фасад db обходит RLS, не полагаясь на то, что сообщает о себе сам вызывающий.
|
|
5
|
+
*/
|
|
6
|
+
/** Что фасад db (корень, db.as(), db.begin()) сообщает модулям пакета. */
|
|
7
|
+
export interface DbInternals {
|
|
8
|
+
/** Подключение в обход RLS (суперпользователь, BYPASSRLS, член роли-владельца): видит строки всех арендаторов. */
|
|
9
|
+
readonly allTenants: boolean;
|
|
10
|
+
/** Фасад явной транзакции db.begin(). */
|
|
11
|
+
readonly explicit: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
|
|
14
|
+
export declare function registerDb(db: object, info: DbInternals): void;
|
|
15
|
+
/** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
|
|
16
|
+
export declare function internalsOf(db: unknown): DbInternals | undefined;
|
package/dist/internal.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Внутренний канал между модулями пакета (план 1.1, этап 5): свойства подключения, которых нет в публичном API.
|
|
3
|
+
* Модуля нет в exports package.json — снаружи пакета его не импортировать; letopis/mcp узнаёт по нему, что
|
|
4
|
+
* фасад db обходит RLS, не полагаясь на то, что сообщает о себе сам вызывающий.
|
|
5
|
+
*/
|
|
6
|
+
const facades = new WeakMap();
|
|
7
|
+
/** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
|
|
8
|
+
export function registerDb(db, info) {
|
|
9
|
+
facades.set(db, info);
|
|
10
|
+
}
|
|
11
|
+
/** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
|
|
12
|
+
export function internalsOf(db) {
|
|
13
|
+
return db !== null && (typeof db === 'object' || typeof db === 'function') ? facades.get(db) : undefined;
|
|
14
|
+
}
|