letopis 2.0.0 → 3.0.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.md +484 -414
- package/CHANGELOG.md +321 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +786 -183
- package/api-contract.json +381 -0
- package/dist/acl.d.ts +3 -0
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +2 -1
- package/dist/admin.js +6 -4
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +75 -6
- package/dist/chain.d.ts +38 -17
- package/dist/chain.js +434 -63
- package/dist/cli.js +28 -6
- package/dist/errors.d.ts +19 -1
- package/dist/errors.js +46 -6
- package/dist/index.d.ts +10 -4
- package/dist/index.js +136 -46
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +9 -5
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +633 -0
- package/dist/mcp.js +26 -11
- package/dist/messages/api.d.ts +66 -0
- package/dist/messages/api.js +90 -1
- package/dist/messages/boot.js +4 -4
- package/dist/messages/codes.js +4 -4
- package/dist/messages/index.d.ts +5 -2
- package/dist/messages/index.js +5 -2
- package/dist/messages/log.d.ts +229 -0
- package/dist/messages/log.js +102 -0
- package/dist/messages/model.d.ts +24 -1
- package/dist/messages/model.js +34 -3
- package/dist/messages/query.d.ts +10 -0
- package/dist/messages/query.js +13 -0
- package/dist/messages/sql-core.d.ts +15 -1
- package/dist/messages/sql-core.js +20 -3
- package/dist/messages/sql-time.d.ts +15 -0
- package/dist/messages/sql-time.js +23 -0
- package/dist/messages/write.d.ts +47 -0
- package/dist/messages/write.js +63 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +23 -5
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +1 -1
- package/dist/sync.js +50 -0
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +14 -10
- package/dist/types.d.ts +82 -4
- package/dist/types.js +6 -1
- package/dist/up.d.ts +3 -3
- package/dist/up.js +57 -17
- package/dist/uuid.d.ts +8 -0
- package/dist/uuid.js +9 -1
- package/dist/validate.js +24 -7
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +13 -0
- package/dist/write.d.ts +78 -1
- package/dist/write.js +357 -39
- package/llms.txt +37 -17
- package/package.json +12 -6
- package/sql/20-context.sql +11 -3
- package/sql/30-validate.sql +51 -10
- package/sql/40-class.sql +21 -1
- package/sql/80-auth.sql +88 -17
- package/sql/90-time.sql +34 -6
- package/sql/99-revision.sql +5 -1
- package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
- package/sql/migrate/0010-impersonation-no-account.sql +5 -0
- package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
- package/sql/migrate/README.md +5 -1
package/dist/tx.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LetopisError, fromDbError, lerr, lmsg } from './errors.js';
|
|
2
|
+
import { NOLOG } from './log.js';
|
|
2
3
|
/** Транзиентные ошибки PG, которые снимает повтор: взаимная блокировка, сериализация. */
|
|
3
4
|
export function isTransient(e) {
|
|
4
5
|
const code = e.code;
|
|
@@ -56,15 +57,17 @@ export class Notifier {
|
|
|
56
57
|
channel;
|
|
57
58
|
intervalMs;
|
|
58
59
|
onError;
|
|
60
|
+
log;
|
|
59
61
|
queue = new Set();
|
|
60
62
|
timer = null;
|
|
61
63
|
stopped = false;
|
|
62
64
|
flushing = Promise.resolve();
|
|
63
|
-
constructor(sql, channel, intervalMs, onError = () => { }) {
|
|
65
|
+
constructor(sql, channel, intervalMs, onError = () => { }, log = NOLOG) {
|
|
64
66
|
this.sql = sql;
|
|
65
67
|
this.channel = channel;
|
|
66
68
|
this.intervalMs = intervalMs;
|
|
67
69
|
this.onError = onError;
|
|
70
|
+
this.log = log;
|
|
68
71
|
}
|
|
69
72
|
/** Добавить нагрузки из letopis.changed (строка через запятую). */
|
|
70
73
|
add(changed) {
|
|
@@ -89,7 +92,10 @@ export class Notifier {
|
|
|
89
92
|
return this.flushing;
|
|
90
93
|
const payloads = [...this.queue];
|
|
91
94
|
this.queue.clear();
|
|
92
|
-
this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() =>
|
|
95
|
+
this.flushing = this.flushing.then(() => this.sql `select pg_notify(${this.channel}, c) from unnest(${payloads}::text[]) as c`.then(() => {
|
|
96
|
+
if (this.log.wants('signal.send'))
|
|
97
|
+
this.log.emit('signal.send', { rows: payloads.length });
|
|
98
|
+
}, (e) => {
|
|
93
99
|
// не отправлено (обрыв связи, отказ базы) — обратно в очередь: уйдёт следующим интервалом
|
|
94
100
|
// (в 1.0.1 такие сигналы терялись, и кэш других процессов устаревал до срока)
|
|
95
101
|
for (const c of payloads)
|
|
@@ -204,6 +210,11 @@ export async function runTx(ctx, fn, opts = {}) {
|
|
|
204
210
|
if (bodyDone && !opts.read && dead)
|
|
205
211
|
throw commitUnknown(e);
|
|
206
212
|
if (attempt <= retries && (isTransient(e) || isLogConflict(e))) {
|
|
213
|
+
const log = ctx.log ?? NOLOG;
|
|
214
|
+
if (log.wants('tx.retry')) {
|
|
215
|
+
log.emit('tx.retry', { detail: { attempt, of: retries, sqlstate: e.code,
|
|
216
|
+
...(isLogConflict(e) ? { conflict: 'log_id_rev' } : {}) } });
|
|
217
|
+
}
|
|
207
218
|
await retryDelay(attempt);
|
|
208
219
|
continue;
|
|
209
220
|
}
|
|
@@ -243,10 +254,15 @@ export class ExplicitTx {
|
|
|
243
254
|
failure = null;
|
|
244
255
|
/** В транзакции был сырой SQL (db.sql): после фиксации — весь кэш. */
|
|
245
256
|
raw = false;
|
|
257
|
+
/** Время открытия — для ms у tx.commit и tx.rollback. */
|
|
258
|
+
t0 = performance.now();
|
|
246
259
|
constructor(ctx, conn) {
|
|
247
260
|
this.ctx = ctx;
|
|
248
261
|
this.conn = conn;
|
|
249
262
|
}
|
|
263
|
+
get log() {
|
|
264
|
+
return this.ctx.log ?? NOLOG;
|
|
265
|
+
}
|
|
250
266
|
static async open(ctx, renewed = false) {
|
|
251
267
|
if (!renewed && ctx.before)
|
|
252
268
|
await ctx.before(ctx);
|
|
@@ -267,6 +283,9 @@ export class ExplicitTx {
|
|
|
267
283
|
return ExplicitTx.open(ctx, true);
|
|
268
284
|
throw le;
|
|
269
285
|
}
|
|
286
|
+
const log = ctx.log ?? NOLOG;
|
|
287
|
+
if (log.wants('tx.begin'))
|
|
288
|
+
log.emit('tx.begin', { op: 'begin', detail: { isolation: 'read committed', ...(ctx.reason ? { reason: ctx.reason } : {}) } });
|
|
270
289
|
return new ExplicitTx(ctx, conn);
|
|
271
290
|
}
|
|
272
291
|
/** Транзакция ещё открыта (не зафиксирована и не откачена). */
|
|
@@ -307,6 +326,7 @@ export class ExplicitTx {
|
|
|
307
326
|
await this.conn.unsafe('rollback').catch(() => { });
|
|
308
327
|
release(this.conn, this.broken);
|
|
309
328
|
this.failure = this.broken;
|
|
329
|
+
this.rolledBack('error', this.broken);
|
|
310
330
|
throw this.broken;
|
|
311
331
|
}
|
|
312
332
|
let changed = null;
|
|
@@ -331,18 +351,40 @@ export class ExplicitTx {
|
|
|
331
351
|
catch (e) {
|
|
332
352
|
this.failure ??= isConnectionError(e) ? commitUnknown(e) : fromDbError(e);
|
|
333
353
|
release(this.conn, e);
|
|
354
|
+
this.rolledBack('error', this.failure);
|
|
334
355
|
throw this.failure;
|
|
335
356
|
}
|
|
336
357
|
this.conn.release();
|
|
358
|
+
// транзакция с записью (write, delete, restore, db.sql, права, креды) — tx.commit уровня info: это и есть
|
|
359
|
+
// доказательство фиксации её событий записи (у них то же поле tx); без записи — debug, как прежде
|
|
360
|
+
const writes = this.log.writes ?? 0;
|
|
361
|
+
const level = writes > 0 ? 'info' : 'debug';
|
|
362
|
+
if (this.log.level(level)) {
|
|
363
|
+
this.log.emit('tx.commit', { op: 'commit', level, ms: performance.now() - this.t0,
|
|
364
|
+
detail: { writes, ...(changed ? { signals: changed.split(',').filter(Boolean).length } : {}) } });
|
|
365
|
+
}
|
|
337
366
|
this.ctx.notifier?.add(changed);
|
|
338
367
|
if (changed)
|
|
339
368
|
this.ctx.onChanged?.(changedList(changed), this.raw);
|
|
340
369
|
this.ctx.after?.();
|
|
341
370
|
}
|
|
371
|
+
/** Событие отката (tx.rollback) и ошибка, которой он вызван. */
|
|
372
|
+
rolledBack(reason, err) {
|
|
373
|
+
const log = this.log;
|
|
374
|
+
if (!log.enabled)
|
|
375
|
+
return;
|
|
376
|
+
if (err !== undefined)
|
|
377
|
+
log.error(err, { op: 'commit', ms: performance.now() - this.t0 });
|
|
378
|
+
if (log.wants('tx.rollback')) {
|
|
379
|
+
log.emit('tx.rollback', { op: reason === 'error' ? 'commit' : 'rollback', ms: performance.now() - this.t0,
|
|
380
|
+
detail: { reason, writes: log.writes ?? 0, ...(this.broken ? { broken: true } : {}) } });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
342
383
|
async rollback() {
|
|
343
384
|
if (this.done)
|
|
344
385
|
return;
|
|
345
386
|
this.done = true;
|
|
387
|
+
this.rolledBack('explicit');
|
|
346
388
|
try {
|
|
347
389
|
// соединение, оборванное ошибкой, не трогаем: postgres.js уронил бы процесс записью в закрытый сокет
|
|
348
390
|
if (!isConnectionError(this.broken))
|
package/dist/typed.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ChainCore, ChainRef } from './chain.js';
|
|
18
18
|
import type { Cursor, Op, OP, Path, Row, Scalar } from './types.js';
|
|
19
|
-
import type { IncMarker } from './write.js';
|
|
19
|
+
import type { DeleteOptions, IncMarker, RestoreOptions } from './write.js';
|
|
20
20
|
/**
|
|
21
21
|
* То, что цепочкам нужно от модели: имя, алиас и типы для вывода — данные строки, концы строки, концы класса (роль →
|
|
22
22
|
* `{ targets: имена классов целей; many; required }`) и имена класса и его предков. Модели letopis/model и объявления
|
|
@@ -290,16 +290,20 @@ export type TypedChain<C, M extends readonly AnyModelLike[]> = Omit<ChainCore, R
|
|
|
290
290
|
upsert(data: Partial<DataOf<C>>, opts?: {
|
|
291
291
|
reason?: string;
|
|
292
292
|
}): TypedChain<C, M>;
|
|
293
|
-
/**
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
293
|
+
/**
|
|
294
|
+
* Без опций — превью. `{ confirm: true, expect: превью }` — удалить ровно показанное (цели сужены фильтром, id, `tags()`,
|
|
295
|
+
* `owner()` или `limit()`); `{ confirm: true }` без `expect` — `invalid_query` (`write.confirm_without_expect`);
|
|
296
|
+
* осознанно удалить текущий состав без сверки — `{ confirm: true, all: true }`.
|
|
297
|
+
*/
|
|
298
|
+
delete(opts?: DeleteOptions): TypedChain<C, M>;
|
|
299
299
|
forUpdate(): TypedChain<C, M>;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
/**
|
|
301
|
+
* `{ expect }` — надгробия `{ id, rev }`: восстановить ровно то, что ушло удалением; расхождение — отказ по смыслу
|
|
302
|
+
* (`write.restore_already`, `write.restore_id_taken`, `write.restore_stale`). `{ expect, partial: true }` — что можно, а
|
|
303
|
+
* остальное — строки `$action: 'skip'` с причиной `$why`. Без `expect` — `invalid_query` (`write.restore_without_expect`);
|
|
304
|
+
* осознанно вернуть все удалённые цели шага без сверки — `{ all: true }`.
|
|
305
|
+
*/
|
|
306
|
+
restore(opts: RestoreOptions): TypedChain<C, M>;
|
|
303
307
|
cache(opts?: {
|
|
304
308
|
ttl?: number;
|
|
305
309
|
}): TypedChain<C, M>;
|
package/dist/types.d.ts
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии 2.x `up()` обновляет только с
|
|
7
7
|
* `upgrade: true`, а `connect()` о ней предупреждает.
|
|
8
8
|
*/
|
|
9
|
-
export declare const DDL_REVISION =
|
|
9
|
+
export declare const DDL_REVISION = 11;
|
|
10
|
+
/**
|
|
11
|
+
* Префикс версии в имени схемы `v<N>.<имя>`: `up({ schema: 'salon' })` и `connect({ schema: 'salon' })` дописывают его к
|
|
12
|
+
* имени без точки — `v2.salon`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SCHEMA_VERSION = "v2";
|
|
10
15
|
/**
|
|
11
16
|
* Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
|
|
12
17
|
* сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
|
|
@@ -52,8 +57,16 @@ export interface Row<D = Record<string, unknown>, L = Record<string, string | st
|
|
|
52
57
|
$depth?: number;
|
|
53
58
|
/** Что сделал upsert. */
|
|
54
59
|
$upsert?: 'created' | 'updated' | 'unchanged';
|
|
55
|
-
/**
|
|
56
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Превью удаления (delete() без confirm): удалится, снимется конец или помешает (restrict). В ответе
|
|
62
|
+
* `restore({ expect, partial: true })` — `skip`: цель не восстановлена, причина — `$why`.
|
|
63
|
+
*/
|
|
64
|
+
$action?: 'delete' | 'unset' | 'restrict' | 'skip';
|
|
65
|
+
/**
|
|
66
|
+
* Почему цель `restore({ expect, partial: true })` не восстановлена: `restored` — её уже восстановили, `reissued` — id
|
|
67
|
+
* занял новый объект с тем же ключом, `changed` — после показанного удаления её меняли (удалили снова и т. п.).
|
|
68
|
+
*/
|
|
69
|
+
$why?: 'restored' | 'reissued' | 'changed';
|
|
57
70
|
}
|
|
58
71
|
/** Путь paths(): узел на каждый шаг цепочки. */
|
|
59
72
|
export type Path = Record<string, Row>;
|
|
@@ -102,7 +115,10 @@ export interface ChainMods {
|
|
|
102
115
|
/** Срез «как было на момент T» (ISO). */
|
|
103
116
|
asOf?: string;
|
|
104
117
|
after?: Cursor;
|
|
105
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Не действует: с 2.1 `withDeleted()` — модификатор своего шага (флаг шага цепочки), а не всей выборки. Поле оставлено,
|
|
120
|
+
* чтобы тип не менялся.
|
|
121
|
+
*/
|
|
106
122
|
withDeleted?: boolean;
|
|
107
123
|
/** Чтение с блокировкой строк последнего шага (только в db.begin()). */
|
|
108
124
|
forUpdate?: boolean;
|
|
@@ -121,3 +137,65 @@ export interface QueryEvent {
|
|
|
121
137
|
/** ms > slowMs. */
|
|
122
138
|
slow: boolean;
|
|
123
139
|
}
|
|
140
|
+
/** Порог логирования: события этого уровня и выше; `'off'` — ничего. */
|
|
141
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'off';
|
|
142
|
+
/** Событие логирования (каталог событий — lib/src/messages/log.ts). Поля без значения в событии отсутствуют. */
|
|
143
|
+
export interface LogEvent {
|
|
144
|
+
/** Время события, ISO 8601 (UTC). */
|
|
145
|
+
ts: string;
|
|
146
|
+
level: Exclude<LogLevel, 'off'>;
|
|
147
|
+
/** Имя события: connect, read, write, delete, error, sql и др. */
|
|
148
|
+
event: string;
|
|
149
|
+
/** Операция: терминал или глагол цепочки, метод db, auth, acl, инструмент MCP. */
|
|
150
|
+
op?: string;
|
|
151
|
+
/** Длительность операции, мс (с точностью до 0,1). */
|
|
152
|
+
ms?: number;
|
|
153
|
+
/** Арендатор сессии фасада. */
|
|
154
|
+
tenant?: string;
|
|
155
|
+
/** Аккаунт фасада: аккаунт сессии или тот, от чьего имени действует db.as(). */
|
|
156
|
+
account?: string;
|
|
157
|
+
/** Метка транзакции db.begin() или db.transaction(). */
|
|
158
|
+
tx?: string;
|
|
159
|
+
/** Цепочка в читаемом виде: `Shop({"name":"A"}).Product.limit(10).rows()`; данные записи — только именами полей. */
|
|
160
|
+
chain?: string;
|
|
161
|
+
/** Строк в ответе или затронуто. */
|
|
162
|
+
rows?: number;
|
|
163
|
+
/** Код ошибки letopis (или SQLSTATE ошибки базы) — у error. */
|
|
164
|
+
code?: string;
|
|
165
|
+
/** Ключ каталога сообщений — у error и warning. */
|
|
166
|
+
key?: string;
|
|
167
|
+
/** Текст ошибки или предупреждения. */
|
|
168
|
+
msg?: string;
|
|
169
|
+
/** Подробности события; секреты замаскированы. */
|
|
170
|
+
detail?: Record<string, unknown>;
|
|
171
|
+
/** Причина ошибки (исходная ошибка базы и т. п.) — у error. */
|
|
172
|
+
cause?: {
|
|
173
|
+
name?: string;
|
|
174
|
+
message: string;
|
|
175
|
+
code?: string;
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Логгер-функция. */
|
|
179
|
+
export type LogFn = (event: LogEvent) => unknown;
|
|
180
|
+
/** Логгер-объект: pino, console и подобные — вызов `logger[уровень](событие, событие.event)`. */
|
|
181
|
+
export interface LogObject {
|
|
182
|
+
debug?(event: LogEvent, msg: string): unknown;
|
|
183
|
+
info?(event: LogEvent, msg: string): unknown;
|
|
184
|
+
warn?(event: LogEvent, msg: string): unknown;
|
|
185
|
+
error?(event: LogEvent, msg: string): unknown;
|
|
186
|
+
log?(event: LogEvent, msg: string): unknown;
|
|
187
|
+
}
|
|
188
|
+
/** Логгер: функция или объект с методами уровней. */
|
|
189
|
+
export type Logger = LogFn | LogObject;
|
|
190
|
+
/** Опции логирования connect() и up(). */
|
|
191
|
+
export interface LogOptions {
|
|
192
|
+
/**
|
|
193
|
+
* Логгер: функция `(событие) => void` или объект с методами `debug`, `info`, `warn`, `error` (pino, console). Без него
|
|
194
|
+
* и с переменной окружения `LETOPIS_LOG=<уровень>` события идут JSON-строками в stderr.
|
|
195
|
+
*/
|
|
196
|
+
log?: Logger;
|
|
197
|
+
/** Порог: `'debug' | 'info' | 'warn' | 'error' | 'off'`, по умолчанию `'info'` (или уровень из `LETOPIS_LOG`). */
|
|
198
|
+
logLevel?: LogLevel;
|
|
199
|
+
/** Ещё имена полей, значения которых маскируются в событиях (пароли, токены и ключи маскируются всегда). */
|
|
200
|
+
redact?: readonly string[];
|
|
201
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии 2.x `up()` обновляет только с
|
|
7
7
|
* `upgrade: true`, а `connect()` о ней предупреждает.
|
|
8
8
|
*/
|
|
9
|
-
export const DDL_REVISION =
|
|
9
|
+
export const DDL_REVISION = 11;
|
|
10
|
+
/**
|
|
11
|
+
* Префикс версии в имени схемы `v<N>.<имя>`: `up({ schema: 'salon' })` и `connect({ schema: 'salon' })` дописывают его к
|
|
12
|
+
* имени без точки — `v2.salon`.
|
|
13
|
+
*/
|
|
14
|
+
export const SCHEMA_VERSION = 'v2';
|
|
10
15
|
/**
|
|
11
16
|
* Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
|
|
12
17
|
* сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
|
package/dist/up.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import postgres from 'postgres';
|
|
2
|
+
import { SCHEMA_VERSION, type LogOptions } from './types.js';
|
|
2
3
|
import { type PgliteOptions, type PgliteServer } from './pglite.js';
|
|
3
4
|
import { type SignalMode } from './signals.js';
|
|
4
5
|
export declare const SYSTEM_ID = "2eba6d0a-1edd-4bb6-a85a-a703dab49035";
|
|
5
|
-
export
|
|
6
|
+
export { SCHEMA_VERSION };
|
|
6
7
|
/**
|
|
7
8
|
* Первая ревизия движка 2.0 (план 2.0, редакция 2): схема ниже неё — letopis 1.x или схема без штампа ревизии. Её
|
|
8
9
|
* `up()` не обновляет, а `connect()` к ней не подключается: 2.0 ставится только заново (`up({ fresh: true })`), данные
|
|
@@ -84,7 +85,7 @@ export declare function install(sql: postgres.Sql, opts: InstallOptions): Promis
|
|
|
84
85
|
}>;
|
|
85
86
|
/** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
|
|
86
87
|
export declare function migrationFiles(dir: string, from: number): Promise<string[]>;
|
|
87
|
-
export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'migrateFrom'
|
|
88
|
+
export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'migrateFrom'>, LogOptions {
|
|
88
89
|
/** Администраторская строка подключения (роль-установщик). */
|
|
89
90
|
dsn?: string;
|
|
90
91
|
/** Готовый пул postgres.js вместо dsn (база уже существует). */
|
|
@@ -221,4 +222,3 @@ export interface TenantResult {
|
|
|
221
222
|
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
222
223
|
*/
|
|
223
224
|
export declare function createTenant(sql: postgres.Sql, schema: string, opts: TenantOptions, owner?: string): Promise<TenantResult>;
|
|
224
|
-
export {};
|
package/dist/up.js
CHANGED
|
@@ -16,7 +16,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { join, resolve } from 'node:path';
|
|
17
17
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
18
|
import postgres from 'postgres';
|
|
19
|
-
import { DDL_REVISION, RESERVED_CLASS_NAMES } from './types.js';
|
|
19
|
+
import { DDL_REVISION, RESERVED_CLASS_NAMES, SCHEMA_VERSION } from './types.js';
|
|
20
|
+
import { makeLog, maskDsn, traceSql, traced } from './log.js';
|
|
20
21
|
import { startPglite } from './pglite.js';
|
|
21
22
|
import { hashPassword } from './auth.js';
|
|
22
23
|
import { checkOptions, lerr, lmsg } from './errors.js';
|
|
@@ -24,7 +25,7 @@ import { sqlCatalog } from './messages/index.js';
|
|
|
24
25
|
import { v5Id } from './uuid.js';
|
|
25
26
|
import { SIGNAL_MODES } from './signals.js';
|
|
26
27
|
export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
|
|
27
|
-
export
|
|
28
|
+
export { SCHEMA_VERSION };
|
|
28
29
|
/**
|
|
29
30
|
* Первая ревизия движка 2.0 (план 2.0, редакция 2): схема ниже неё — letopis 1.x или схема без штампа ревизии. Её
|
|
30
31
|
* `up()` не обновляет, а `connect()` к ней не подключается: 2.0 ставится только заново (`up({ fresh: true })`), данные
|
|
@@ -321,7 +322,7 @@ export async function migrationFiles(dir, from) {
|
|
|
321
322
|
const UP_OPTIONS = {
|
|
322
323
|
dsn: true, sql: true, schema: true, fresh: true, upgrade: true, quiet: true, waitTimeoutMs: true, pglite: true, tenant: true,
|
|
323
324
|
login: true, owner: true, app: true, seeds: true, migrationsDir: true, serviceKey: true,
|
|
324
|
-
signals: true,
|
|
325
|
+
signals: true, log: true, logLevel: true, redact: true,
|
|
325
326
|
};
|
|
326
327
|
/** Проба сервера: ok | нет сервера | нет базы | запускается | роковая ошибка (неверный пароль). */
|
|
327
328
|
export async function pgPing(dsn) {
|
|
@@ -397,7 +398,30 @@ export async function schemaRevision(sql, schema) {
|
|
|
397
398
|
*/
|
|
398
399
|
export async function up(opts) {
|
|
399
400
|
checkOptions('up', opts, UP_OPTIONS);
|
|
400
|
-
|
|
401
|
+
// логирование 3.0: события up, migrate, up.step, tenant.create, warning, error, sql — в логгер opts.log (или LETOPIS_LOG)
|
|
402
|
+
const lg = makeLog({ log: opts.log, logLevel: opts.logLevel, redact: opts.redact });
|
|
403
|
+
return traced(lg, 'up', { op: 'up' }, () => upCore(opts, lg), (r) => ({
|
|
404
|
+
detail: {
|
|
405
|
+
// schema — фактическое имя (имя без точки получает префикс версии: 'salon' → 'v2.salon'); requested — как передано
|
|
406
|
+
schema: r.schema, ...(opts.schema !== r.schema ? { requested: opts.schema } : {}),
|
|
407
|
+
created: r.created, upgraded: r.upgraded, revision: r.revision, signals: r.signals,
|
|
408
|
+
...(opts.dsn ? { dsn: maskDsn(opts.dsn) } : {}), ...(r.pglite ? { pglite: true } : {}), ...(opts.fresh ? { fresh: true } : {}),
|
|
409
|
+
analyzed: r.analyzed.length, warnings: r.warnings.length, ...(r.serviceKey ? { issuedKey: true } : {}),
|
|
410
|
+
...(r.tenant ? { tenant: r.tenant.tenant } : {}), ...(r.login ? { login: r.login.user } : {}),
|
|
411
|
+
},
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
/** up() после проверки опций; lg — логгер. */
|
|
415
|
+
async function upCore(opts, lg) {
|
|
416
|
+
// сообщения шагов: при заданном логгере — событие up.step вместо console.log
|
|
417
|
+
const log = opts.quiet ? () => { } : lg.active ? (m) => lg.emit('up.step', { op: 'up', msg: m }) : (m) => console.log(`letopis.up: ${m}`);
|
|
418
|
+
/** Предупреждение: в логгер (warning), иначе console.warn без quiet. */
|
|
419
|
+
const warn = (w) => {
|
|
420
|
+
if (lg.active)
|
|
421
|
+
lg.warning(undefined, w);
|
|
422
|
+
else if (!opts.quiet)
|
|
423
|
+
console.warn(`letopis.up: ${w}`);
|
|
424
|
+
};
|
|
401
425
|
const schema = opts.schema.includes('.') ? opts.schema : `${SCHEMA_VERSION}.${opts.schema}`;
|
|
402
426
|
if (!SCHEMA_NAME.test(schema))
|
|
403
427
|
throw lerr('boot.up_schema_name', { schema: opts.schema });
|
|
@@ -432,13 +456,14 @@ export async function up(opts) {
|
|
|
432
456
|
}
|
|
433
457
|
sql = postgres(opts.dsn, { max: 1, onnotice: () => { } });
|
|
434
458
|
}
|
|
459
|
+
// порог debug — событие sql на каждый запрос установки (тексты create/alter role — без пароля)
|
|
460
|
+
sql = traceSql(sql, lg);
|
|
435
461
|
try {
|
|
436
462
|
const [srv] = await sql `select current_setting('server_version_num')::int as v, pg_encoding_to_char(encoding) as enc
|
|
437
463
|
from pg_database where datname = current_database()`;
|
|
438
464
|
const warnings = checkServer(srv.v, srv.enc);
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
console.warn(`letopis.up: ${w}`);
|
|
465
|
+
for (const w of warnings)
|
|
466
|
+
warn(w);
|
|
442
467
|
// ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
|
|
443
468
|
const before = opts.fresh ? null : await schemaRevision(sql, schema);
|
|
444
469
|
if (before !== null && before > DDL_REVISION) {
|
|
@@ -451,10 +476,14 @@ export async function up(opts) {
|
|
|
451
476
|
if (before !== null && before < DDL_REVISION && !opts.upgrade) {
|
|
452
477
|
throw new Error(`letopis.up: ${lmsg('boot.schema_older', { schema, rev: before, want: DDL_REVISION })}`);
|
|
453
478
|
}
|
|
454
|
-
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, login: _l, ...rest } = opts;
|
|
479
|
+
const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, login: _l, log: _lg, logLevel: _ll, redact: _rd, ...rest } = opts;
|
|
480
|
+
const t0 = performance.now();
|
|
455
481
|
const { found, ...res } = await install(sql, {
|
|
456
482
|
...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
|
|
457
483
|
});
|
|
484
|
+
if (found !== null && found < DDL_REVISION && lg.wants('migrate')) {
|
|
485
|
+
lg.emit('migrate', { op: 'up', ms: performance.now() - t0, rows: res.migrations.length, detail: { schema, from: found, to: DDL_REVISION, files: res.migrations } });
|
|
486
|
+
}
|
|
458
487
|
if (opts.fresh)
|
|
459
488
|
log(lmsg('boot.log_fresh', { schema }));
|
|
460
489
|
// ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть. От роли владельца, как у
|
|
@@ -476,8 +505,7 @@ export async function up(opts) {
|
|
|
476
505
|
if (left.length) {
|
|
477
506
|
const w = lmsg('boot.analyze_skipped', { owner: res.owner, tables: left.map((t) => `${schema}.${t}`).join(', ') });
|
|
478
507
|
warnings.push(w);
|
|
479
|
-
|
|
480
|
-
console.warn(`letopis.up: ${w}`);
|
|
508
|
+
warn(w);
|
|
481
509
|
}
|
|
482
510
|
// хабы с зарезервированными именами, заведённые мимо проверки базы (план 2.0, этап 4, п. 2; 7.10 №3): накат их
|
|
483
511
|
// оставляет и называет, новых таких база не пишет. Строки Class — от роли владельца, как ANALYZE: у установщика без
|
|
@@ -491,12 +519,13 @@ export async function up(opts) {
|
|
|
491
519
|
if (reserved.length) {
|
|
492
520
|
const w = lmsg('boot.reserved_hubs', { hubs: reserved.map((h) => `${h.name} (${h.tenant})`).join(', ') });
|
|
493
521
|
warnings.push(w);
|
|
494
|
-
|
|
495
|
-
console.warn(`letopis.up: ${w}`);
|
|
522
|
+
warn(w);
|
|
496
523
|
}
|
|
497
524
|
const revision = (await schemaRevision(sql, schema)) ?? 0;
|
|
498
525
|
log(lmsg(found === null ? 'boot.log_installed' : found < DDL_REVISION ? 'boot.log_upgraded' : 'boot.log_reapplied', { schema, revision, from: found }));
|
|
499
|
-
const tenant = opts.tenant
|
|
526
|
+
const tenant = opts.tenant
|
|
527
|
+
? await traced(lg, 'tenant.create', { op: 'createTenant' }, () => createTenant(sql, schema, opts.tenant, opts.owner), (t) => ({ tenant: t.tenant, ...(t.user ? { account: t.user } : {}), detail: { name: opts.tenant.name, created: t.created, user: Boolean(t.user) } }))
|
|
528
|
+
: undefined;
|
|
500
529
|
const login = opts.login ? await ensureLogin(sql, opts.login, res.owner, res.app, opts.dsn ?? lite?.dsn) : undefined;
|
|
501
530
|
if (login)
|
|
502
531
|
log(lmsg(login.created ? 'boot.log_login_created' : 'boot.log_login_updated', { user: login.user }));
|
|
@@ -597,11 +626,22 @@ function checkTenant(opts) {
|
|
|
597
626
|
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
598
627
|
*/
|
|
599
628
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
629
|
+
// admin — пул postgres(): строка DSN, подключение connect() letopis или другой объект упали бы голым TypeError на
|
|
630
|
+
// sql.begin (3.0)
|
|
631
|
+
const pool = sql;
|
|
632
|
+
if (typeof pool !== 'function' || typeof pool.begin !== 'function' || typeof pool.unsafe !== 'function') {
|
|
633
|
+
throw lerr('api.create_tenant_admin', { type: pool === null ? 'null' : typeof pool });
|
|
634
|
+
}
|
|
635
|
+
// имя схемы — как у connect() и up(): без точки — с префиксом версии (v2.salon)
|
|
636
|
+
if (typeof schema === 'string' && !schema.includes('.'))
|
|
637
|
+
schema = `${SCHEMA_VERSION}.${schema}`;
|
|
600
638
|
const roles = checkTenant(opts);
|
|
601
639
|
const S = quoteIdent(schema);
|
|
602
640
|
const tenant = v5Id(SYSTEM_ID, 'Account', [`tenant:${opts.name}`]);
|
|
603
641
|
const u = opts.user;
|
|
604
|
-
|
|
642
|
+
// логин без крайних пробелов, как у auth.setPassword() и verifyPassword() (план 2.1, A12)
|
|
643
|
+
const login = u ? (typeof u.login === 'string' ? u.login.trim() : u.login) : '';
|
|
644
|
+
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${login}`]) : null;
|
|
605
645
|
const hash = u ? await hashPassword(u.password) : null;
|
|
606
646
|
return sql.begin(async (tx) => {
|
|
607
647
|
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
@@ -624,18 +664,18 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
624
664
|
}
|
|
625
665
|
let token = null;
|
|
626
666
|
if (u && user) {
|
|
627
|
-
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login
|
|
667
|
+
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login }))) {
|
|
628
668
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'Account', $2::uuid, '{}', $3::jsonb)`, [user, SYSTEM_ID, { name: u.name, categories: ['User'], enabled: true }]);
|
|
629
669
|
}
|
|
630
670
|
const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
|
|
631
671
|
// существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
|
|
632
|
-
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login
|
|
672
|
+
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login, name: opts.name }))) {
|
|
633
673
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
|
|
634
674
|
}
|
|
635
675
|
const kind = u.kind ?? 'PASSWORD';
|
|
636
676
|
await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta)
|
|
637
677
|
select $1::uuid, $2, $3, convert_to($4, 'UTF8'), '{"confirmed": true}'::jsonb
|
|
638
|
-
where not exists (select 1 from ${S}.secret where kind = $2 and casefold(ident) collate pg_unicode_fast = casefold($3) collate pg_unicode_fast and deleted_at is null)`, [user, kind,
|
|
678
|
+
where not exists (select 1 from ${S}.secret where kind = $2 and casefold(ident) collate pg_unicode_fast = casefold($3) collate pg_unicode_fast and deleted_at is null)`, [user, kind, login, hash]);
|
|
639
679
|
token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user, u.ttl ?? '7 days', { tenant }]))[0].t;
|
|
640
680
|
}
|
|
641
681
|
return { tenant, user, token, created };
|
package/dist/uuid.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** uuid в каноническом виде 8-4-4-4-12 (регистр любой) — общий для построителя, загрузчика и MCP. */
|
|
2
|
+
export declare const UUID_RE: RegExp;
|
|
3
|
+
/**
|
|
4
|
+
* Любой вид uuid, который принимает приведение PostgreSQL `::uuid`: 32 шестнадцатеричные цифры, дефис — после любой
|
|
5
|
+
* группы из четырёх (или без дефисов), весь в фигурных скобках или без них. Фильтр с таким id работал и до проверки,
|
|
6
|
+
* поэтому проверка до запроса (sql.ts, compileFilter) отказывает только тому, что отклонила бы база.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PG_UUID_RE: RegExp;
|
|
1
9
|
/** RFC 4122 v5: sha1(пространство + имя); одно имя в одном пространстве — один id. */
|
|
2
10
|
export declare function uuidv5(name: string, ns: string): string;
|
|
3
11
|
/** Имя v5: JSON-массив из имени класса и значений ключа — как `jsonb_build_array(…)::text` в базе. */
|
package/dist/uuid.js
CHANGED
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { hash } from 'node:crypto';
|
|
9
9
|
import { lerr } from './errors.js';
|
|
10
|
-
|
|
10
|
+
/** uuid в каноническом виде 8-4-4-4-12 (регистр любой) — общий для построителя, загрузчика и MCP. */
|
|
11
|
+
export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const UUID = UUID_RE;
|
|
13
|
+
/**
|
|
14
|
+
* Любой вид uuid, который принимает приведение PostgreSQL `::uuid`: 32 шестнадцатеричные цифры, дефис — после любой
|
|
15
|
+
* группы из четырёх (или без дефисов), весь в фигурных скобках или без них. Фильтр с таким id работал и до проверки,
|
|
16
|
+
* поэтому проверка до запроса (sql.ts, compileFilter) отказывает только тому, что отклонила бы база.
|
|
17
|
+
*/
|
|
18
|
+
export const PG_UUID_RE = /^(?:[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}|\{[0-9a-f]{4}(?:-?[0-9a-f]{4}){7}\})$/i;
|
|
11
19
|
// байты пространств имён (арендаторов): в загрузке их единицы, а проверка и разбор шли на каждый id
|
|
12
20
|
const NS = new Map();
|
|
13
21
|
function nsBytes(ns) {
|
package/dist/validate.js
CHANGED
|
@@ -104,6 +104,11 @@ const prop = (sel, k) => (root) => sel(root).flatMap((n) => (isObj(n) && Object.
|
|
|
104
104
|
const items = (sel) => (root) => sel(root).flatMap((n) => (Array.isArray(n) ? n : []));
|
|
105
105
|
const others = (sel, known) => (root) => sel(root).flatMap((n) => (isObj(n) ? Object.keys(n).filter((k) => !known.includes(k)).map((k) => n[k]) : []));
|
|
106
106
|
const keys = (sel) => (root) => sel(root).flatMap((n) => (isObj(n) ? Object.keys(n) : []));
|
|
107
|
+
/** Допустимые значения перечисления для сообщения нарушения — как у базы (validate_data): JSON через запятую, до 20. */
|
|
108
|
+
function enumValues(values) {
|
|
109
|
+
const shown = values.slice(0, 20).map((v) => JSON.stringify(v)).join(', ');
|
|
110
|
+
return values.length > 20 ? `${shown}, …` : shown;
|
|
111
|
+
}
|
|
107
112
|
/** Описание не компилируется (`invalid_class`): ключ каталога и параметры; путь узла — параметр node. */
|
|
108
113
|
function fail(path, key, params) {
|
|
109
114
|
throw lerr(key, { ...params, node: JSON.stringify(path) });
|
|
@@ -127,13 +132,15 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
127
132
|
if (sch.enum !== undefined || sch.const !== undefined) {
|
|
128
133
|
const keyword = sch.enum !== undefined ? 'enum' : 'const';
|
|
129
134
|
const values = (sch.enum ?? [sch.const]);
|
|
135
|
+
// сообщение называет допустимые значения (план 2.1, A8)
|
|
136
|
+
const msg = lmsg('model.issue_enum', { values: enumValues(values) });
|
|
130
137
|
if (values.some((v) => typeof v === 'object' && v !== null)) {
|
|
131
138
|
if (inAny)
|
|
132
139
|
fail(path, 'model.validator_enum_objects_in_any_of');
|
|
133
|
-
out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null :
|
|
140
|
+
out.push({ path, keyword, select: sel, test: () => true, extra: (v) => (values.some((x) => jsonEqual(x, v)) ? null : msg) });
|
|
134
141
|
}
|
|
135
142
|
else {
|
|
136
|
-
out.push({ path, keyword, select: sel, test: (v) => values.some((x) => typeOf(x) === typeOf(v) && jsonEqual(x, v)) });
|
|
143
|
+
out.push({ path, keyword, select: sel, test: (v) => values.some((x) => typeOf(x) === typeOf(v) && jsonEqual(x, v)), explain: () => msg });
|
|
137
144
|
}
|
|
138
145
|
}
|
|
139
146
|
const num = (f, b) => (v) => typeof v !== 'number' || f(v, b);
|
|
@@ -177,8 +184,13 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
177
184
|
const min = sch.minLength ?? 0;
|
|
178
185
|
const max = sch.maxLength ?? Infinity;
|
|
179
186
|
out.push({ path, keyword: sch.minLength !== undefined ? 'minLength' : 'maxLength', select: sel, test: () => true,
|
|
180
|
-
|
|
181
|
-
|
|
187
|
+
// текст называет границу (второй раунд 2.1, A6), как у базы (30-validate.sql, validate_data)
|
|
188
|
+
extra: (v) => {
|
|
189
|
+
if (typeof v !== 'string')
|
|
190
|
+
return null;
|
|
191
|
+
const n = [...v].length;
|
|
192
|
+
return n < min ? lmsg('model.issue_string_too_short', { limit: min }) : n > max ? lmsg('model.issue_string_too_long', { limit: max }) : null;
|
|
193
|
+
} });
|
|
182
194
|
}
|
|
183
195
|
if (sch.minItems !== undefined)
|
|
184
196
|
out.push({ path, keyword: 'minItems', select: sel, test: (v) => !Array.isArray(v) || v.length >= sch.minItems });
|
|
@@ -211,7 +223,9 @@ function compileNode(s, sel, path, inAny, out) {
|
|
|
211
223
|
compileNode(v, prop(sel, k), [...path, k], inAny, out);
|
|
212
224
|
}
|
|
213
225
|
if (sch.additionalProperties === false) {
|
|
214
|
-
|
|
226
|
+
// сообщение называет лишние ключи (план 2.1, A8)
|
|
227
|
+
out.push({ path, keyword: 'additionalProperties', select: sel, test: (v) => !isObj(v) || Object.keys(v).every((k) => known.includes(k)),
|
|
228
|
+
explain: (v) => lmsg('model.issue_additional', { keys: Object.keys(v).filter((k) => !known.includes(k)).slice(0, 20).join(', ') }) });
|
|
215
229
|
}
|
|
216
230
|
else if (isObj(sch.additionalProperties)) {
|
|
217
231
|
compileNode(sch.additionalProperties, others(sel, known), [...path, '*'], inAny, out);
|
|
@@ -258,8 +272,11 @@ export function compileValidator(schema) {
|
|
|
258
272
|
issues.push({ path: r.path, keyword: r.keyword, message: msg });
|
|
259
273
|
}
|
|
260
274
|
}
|
|
261
|
-
else
|
|
262
|
-
|
|
275
|
+
else {
|
|
276
|
+
const at = nodes.findIndex((n) => !r.test(n));
|
|
277
|
+
if (at >= 0) {
|
|
278
|
+
issues.push({ path: r.path, keyword: r.keyword, message: r.explain ? r.explain(nodes[at]) : lmsg('model.issue_rule', { keyword: r.keyword }) });
|
|
279
|
+
}
|
|
263
280
|
}
|
|
264
281
|
}
|
|
265
282
|
return issues;
|
package/dist/watch.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
2
|
import type { Row } from './types.js';
|
|
3
3
|
import type { Signal } from './signals.js';
|
|
4
|
+
import type { Log } from './log.js';
|
|
4
5
|
export interface WatchOptions {
|
|
5
6
|
/** Откуда: курсор (sub.cursor или курсор события), 'now' (по умолчанию — изменения после подписки) или 'start' (весь журнал). */
|
|
6
7
|
from?: string;
|
|
@@ -39,6 +40,8 @@ export interface WatchCtx {
|
|
|
39
40
|
tenant?: () => string | null;
|
|
40
41
|
/** Подписка закрылась: подключение перестаёт её учитывать (план 2.0, 7.4 №8). */
|
|
41
42
|
onClose?: (s: Subscription) => void;
|
|
43
|
+
/** Логгер фасада (lib/src/log.ts): watch.start, watch.fetch, watch.stop, error. */
|
|
44
|
+
log?: Log;
|
|
42
45
|
}
|
|
43
46
|
/**
|
|
44
47
|
* Будит ли сигнал подписку: свои классы (watched; null — все) и свой арендатор (mine — токен; null —
|
|
@@ -67,6 +70,8 @@ export declare class Subscription implements AsyncIterable<WatchEvent> {
|
|
|
67
70
|
private start;
|
|
68
71
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
69
72
|
private queue;
|
|
73
|
+
/** Выдано событий — для watch.stop. */
|
|
74
|
+
private delivered;
|
|
70
75
|
constructor(ctx: WatchCtx, opts: WatchOptions);
|
|
71
76
|
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
72
77
|
get cursor(): string | null;
|