letopis 1.1.0 → 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 +110 -15
- package/AGENT-CHEATSHEET.md +103 -13
- package/CHANGELOG.md +577 -0
- package/README.en.md +769 -73
- package/README.md +732 -67
- 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 +75 -17
- 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 +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- 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 +20 -1
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +12 -2
- package/sql/10-core.sql +5 -1
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +515 -48
- 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 +89 -12
- 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';
|
|
@@ -160,6 +162,23 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
160
162
|
throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
|
|
161
163
|
return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
|
|
162
164
|
},
|
|
165
|
+
projector: (name, o) => {
|
|
166
|
+
if (tx)
|
|
167
|
+
throw new LetopisError('invalid_query', 'projector() — от корня подключения, а не внутри db.begin()');
|
|
168
|
+
if (!sh.admin)
|
|
169
|
+
throw new LetopisError('admin_required', 'проектору нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
170
|
+
return new Projector({
|
|
171
|
+
sql: sh.sql, schema: sh.schema, S: sh.S, ownerRole: sh.ownerRole,
|
|
172
|
+
...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
|
|
173
|
+
}, name, o);
|
|
174
|
+
},
|
|
175
|
+
rebuildAggregates: async (cls) => {
|
|
176
|
+
if (tx)
|
|
177
|
+
throw new LetopisError('invalid_query', 'rebuildAggregates() — своей транзакцией, не внутри db.begin()');
|
|
178
|
+
if (!sh.admin)
|
|
179
|
+
throw new LetopisError('admin_required', 'ремонту агрегатов нужно администраторское подключение (connect({ allowBypassRls: true }) роли, которая может стать владельцем схемы)');
|
|
180
|
+
return runRebuildAggregates({ sql: sh.sql, S: sh.S, ownerRole: sh.ownerRole }, cls);
|
|
181
|
+
},
|
|
163
182
|
batch: (name, o) => {
|
|
164
183
|
let q = batches.get(name);
|
|
165
184
|
if (!q)
|
|
@@ -171,7 +190,7 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
171
190
|
rollback: async (tr) => (tr ? tr.rollback() : tx ? tx.rollback() : noTx('rollback()')),
|
|
172
191
|
close: () => (tx ? tx.rollback() : sh.close()),
|
|
173
192
|
};
|
|
174
|
-
|
|
193
|
+
const facade = new Proxy(core, {
|
|
175
194
|
get(t, prop, r) {
|
|
176
195
|
if (typeof prop === 'symbol' || prop in t)
|
|
177
196
|
return Reflect.get(t, prop, r);
|
|
@@ -191,6 +210,9 @@ function makeDb(sh, scope, exec, owner, tx) {
|
|
|
191
210
|
return startStep(chainCtx, prop);
|
|
192
211
|
},
|
|
193
212
|
});
|
|
213
|
+
// модули пакета (letopis/mcp) узнают обход RLS по самому подключению, а не со слов вызывающего (internal.ts)
|
|
214
|
+
registerDb(facade, { allTenants: sh.allTenants, explicit: Boolean(tx) });
|
|
215
|
+
return facade;
|
|
194
216
|
}
|
|
195
217
|
export async function connect(opts) {
|
|
196
218
|
if (!SCHEMA_NAME.test(opts.schema))
|
|
@@ -263,32 +285,57 @@ export async function connect(opts) {
|
|
|
263
285
|
const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
|
|
264
286
|
...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
|
|
265
287
|
const root = { tenant: null, registry: new Registry() };
|
|
266
|
-
|
|
288
|
+
// классы арендатора tenant под контекстом txc; null — контекст уже не в этом арендаторе (сессия кончилась,
|
|
289
|
+
// пользователь выбыл из арендатора): тогда прежний реестр остаётся, а не пустеет (доработки после точки Б)
|
|
290
|
+
const loadClasses = (txc, tenant) => runTx(txc, async (q) => {
|
|
291
|
+
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
292
|
+
if (String(t?.t ?? '').toLowerCase() !== tenant.toLowerCase())
|
|
293
|
+
return null;
|
|
294
|
+
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
295
|
+
}, { read: true });
|
|
296
|
+
// реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора
|
|
267
297
|
const others = new Map();
|
|
298
|
+
const loading = new Map();
|
|
268
299
|
// токены классов (режим opaque) — по именам всех реестров подключения
|
|
269
300
|
const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
|
|
270
|
-
|
|
301
|
+
// only — токены арендаторов из сигналов Class (режимы tenant и opaque): реестры db.as() только этих арендаторов
|
|
302
|
+
const refresh = async (only) => {
|
|
271
303
|
const rows = await runTx(ctx, async (q) => {
|
|
272
304
|
const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
|
|
273
305
|
root.tenant = t?.t ?? null;
|
|
274
306
|
return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
|
|
275
307
|
});
|
|
276
308
|
root.registry.load(rows);
|
|
277
|
-
for (const o of others
|
|
278
|
-
|
|
309
|
+
for (const [tenant, o] of others) {
|
|
310
|
+
if (only && !only.has(sig.tenantToken(tenant) ?? ''))
|
|
311
|
+
continue;
|
|
312
|
+
const loaded = await loadClasses(o.txc, tenant).catch(() => null);
|
|
313
|
+
if (loaded)
|
|
314
|
+
o.registry.load(loaded);
|
|
315
|
+
}
|
|
279
316
|
tokens();
|
|
280
317
|
};
|
|
281
318
|
const registryFor = async (tenant, txc) => {
|
|
282
319
|
if (tenant === root.tenant)
|
|
283
320
|
return root.registry;
|
|
284
321
|
const known = others.get(tenant);
|
|
285
|
-
if (known)
|
|
322
|
+
if (known) {
|
|
323
|
+
known.txc = txc;
|
|
286
324
|
return known.registry;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
325
|
+
}
|
|
326
|
+
// одновременные as() нового арендатора получают один реестр
|
|
327
|
+
let pending = loading.get(tenant);
|
|
328
|
+
if (!pending) {
|
|
329
|
+
pending = (async () => {
|
|
330
|
+
const registry = new Registry();
|
|
331
|
+
registry.load(((await loadClasses(txc, tenant)) ?? []));
|
|
332
|
+
others.set(tenant, { registry, txc });
|
|
333
|
+
tokens();
|
|
334
|
+
return registry;
|
|
335
|
+
})().finally(() => loading.delete(tenant));
|
|
336
|
+
loading.set(tenant, pending);
|
|
337
|
+
}
|
|
338
|
+
return pending;
|
|
292
339
|
};
|
|
293
340
|
// ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
|
|
294
341
|
// работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
|
|
@@ -311,7 +358,7 @@ export async function connect(opts) {
|
|
|
311
358
|
// поддельными сигналами) не становится потоком перечитываний
|
|
312
359
|
const resync = throttle(() => {
|
|
313
360
|
passes.clear();
|
|
314
|
-
void fetchKey().then(refresh).catch(() => { });
|
|
361
|
+
void fetchKey().then(() => refresh()).catch(() => { });
|
|
315
362
|
}, 5000);
|
|
316
363
|
// сигнал Class — реестр сразу, как в 1.0: правка описания доходит без задержки; пока идёт перечитывание, новые
|
|
317
364
|
// сигналы сливаются в одно следующее. Три перечитывания подряд — сразу, дальше не чаще раза в 5 с: заливка
|
|
@@ -319,17 +366,28 @@ export async function connect(opts) {
|
|
|
319
366
|
// точки А)
|
|
320
367
|
let reloading = false;
|
|
321
368
|
let again = false;
|
|
369
|
+
// какие реестры db.as() перечитать: токены арендаторов из сигналов или все (сигнал без арендатора)
|
|
370
|
+
let reloadAll = false;
|
|
371
|
+
const reloadTokens = new Set();
|
|
322
372
|
const startReload = bucket(() => {
|
|
323
373
|
reloading = true;
|
|
324
|
-
|
|
374
|
+
const only = reloadAll ? null : new Set(reloadTokens);
|
|
375
|
+
reloadAll = false;
|
|
376
|
+
reloadTokens.clear();
|
|
377
|
+
void refresh(only).catch(() => { }).finally(() => {
|
|
325
378
|
reloading = false;
|
|
326
379
|
if (again) {
|
|
327
380
|
again = false;
|
|
328
|
-
reload();
|
|
381
|
+
reload(undefined);
|
|
329
382
|
}
|
|
330
383
|
});
|
|
331
384
|
}, 3, 5000);
|
|
332
|
-
|
|
385
|
+
// tenant: null — все реестры, строка — токен арендатора сигнала, undefined — то, что уже накоплено
|
|
386
|
+
const reload = (tenant) => {
|
|
387
|
+
if (tenant === null)
|
|
388
|
+
reloadAll = true;
|
|
389
|
+
else if (tenant !== undefined)
|
|
390
|
+
reloadTokens.add(tenant);
|
|
333
391
|
if (reloading) {
|
|
334
392
|
again = true;
|
|
335
393
|
return;
|
|
@@ -343,7 +401,7 @@ export async function connect(opts) {
|
|
|
343
401
|
if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
|
|
344
402
|
passes.clear();
|
|
345
403
|
if (s.kind === 'class' && s.cls === 'Class')
|
|
346
|
-
reload();
|
|
404
|
+
reload(s.tenant);
|
|
347
405
|
else if (s.kind === 'all' || s.kind === 'opaque' || s.kind === 'unknown' || s.kind === 'foreign')
|
|
348
406
|
resync();
|
|
349
407
|
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
|
+
}
|