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/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
- /** Другой процесс уже обслуживает установку (advisory-блокировка): этот вызов ничего не делал. */
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
- * первым делом берёт транзакционную advisory-блокировку (pg_try_advisory_xact_lock), и та снимается
110
- * с фиксацией пачки. Сеансовая блокировка за пулом в режиме транзакций (PgBouncer) оставалась бы на
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
- * первым делом берёт транзакционную advisory-блокировку (pg_try_advisory_xact_lock), и та снимается
16
- * с фиксацией пачки. Сеансовая блокировка за пулом в режиме транзакций (PgBouncer) оставалась бы на
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
- // блокировка и пачка — одним оператором: case вычисляет maintain() только при взятой блокировке
30
- const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select case when pg_try_advisory_xact_lock($1::int, hashtext($2::text))
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
- ...(cmd === 'load' || cmd === 'verify' || cmd === 'maintain' ? { allowBypassRls: true } : {}) });
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
- return Object.assign(v, { cause: e });
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', мс) под advisory-блокировкой: работает один процесс. */
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
- const [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
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
- const tenant = r.tenant;
147
- const registry = await sh.registryFor(tenant, scoped);
148
- return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
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
- return new Proxy(core, {
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
- const loadClasses = (txc) => runTx(txc, (q) => q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`), { read: true });
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
- const refresh = async () => {
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.values())
278
- o.registry.load(await loadClasses(o.txc).catch(() => []));
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
- const registry = new Registry();
288
- registry.load(await loadClasses(txc));
289
- others.set(tenant, { registry, txc });
290
- tokens();
291
- return registry;
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
- void refresh().catch(() => { }).finally(() => {
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
- const reload = () => {
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;
@@ -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
+ }