letopis 2.0.0 → 3.0.1
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 +491 -414
- package/CHANGELOG.md +361 -0
- package/LICENSE +75 -21
- package/LICENSE-COMMERCIAL.md +49 -0
- package/README.md +969 -210
- package/api-contract.json +385 -0
- package/dist/acl.d.ts +7 -2
- package/dist/acl.js +19 -2
- package/dist/admin.d.ts +7 -1
- package/dist/admin.js +16 -9
- package/dist/auth.d.ts +47 -11
- package/dist/auth.js +129 -15
- package/dist/chain.d.ts +52 -25
- package/dist/chain.js +445 -64
- package/dist/cli.js +72 -20
- package/dist/errors.d.ts +23 -6
- package/dist/errors.js +52 -13
- package/dist/index.d.ts +32 -15
- package/dist/index.js +209 -68
- package/dist/internal.d.ts +2 -0
- package/dist/load.d.ts +10 -6
- package/dist/load.js +30 -11
- package/dist/log.d.ts +121 -0
- package/dist/log.js +654 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +34 -16
- package/dist/messages/access.d.ts +10 -0
- package/dist/messages/access.js +13 -0
- package/dist/messages/api.d.ts +76 -0
- package/dist/messages/api.js +109 -7
- package/dist/messages/boot.d.ts +5 -0
- package/dist/messages/boot.js +11 -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 +232 -0
- package/dist/messages/log.js +105 -0
- package/dist/messages/mcp.js +7 -7
- package/dist/messages/model.d.ts +40 -1
- package/dist/messages/model.js +52 -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 +52 -0
- package/dist/messages/write.js +77 -6
- package/dist/model.d.ts +10 -2
- package/dist/model.js +28 -6
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +6 -0
- package/dist/sql.d.ts +2 -0
- package/dist/sql.js +77 -25
- package/dist/sync.d.ts +4 -5
- package/dist/sync.js +53 -4
- package/dist/tx.d.ts +10 -1
- package/dist/tx.js +44 -2
- package/dist/typed.d.ts +18 -14
- package/dist/types.d.ts +91 -8
- package/dist/types.js +8 -3
- package/dist/up.d.ts +8 -5
- package/dist/up.js +71 -23
- 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 +84 -3
- package/dist/write.js +427 -40
- package/llms.txt +38 -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/60-write.sql +18 -6
- 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/types.d.ts
CHANGED
|
@@ -3,17 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/**
|
|
5
5
|
* Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
|
|
6
|
-
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии
|
|
6
|
+
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии (того же поколения `v2`) `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).
|
|
13
18
|
*/
|
|
14
19
|
export declare const ACL_CLASS_NAMES: readonly string[];
|
|
15
20
|
/**
|
|
16
|
-
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и
|
|
21
|
+
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и шаги роли, а также `then`. После шага цепочки эти
|
|
17
22
|
* имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
|
|
18
23
|
*/
|
|
19
24
|
export declare const CHAIN_METHODS: readonly ["then", "entity", "paths", "rows", "first", "ids", "count", "limit", "offset", "sort", "asOf", "withDeleted", "deep", "exact", "sum", "avg", "min", "max", "countBy", "after", "versions", "create", "update", "upsert", "set", "unset", "add", "remove", "delete", "restore", "alias", "tags", "owner", "forUpdate", "cache"];
|
|
@@ -46,14 +51,27 @@ export interface Row<D = Record<string, unknown>, L = Record<string, string | st
|
|
|
46
51
|
/** Вид операции версии: create, update, delete, restore, … */
|
|
47
52
|
op: string;
|
|
48
53
|
reason?: string;
|
|
49
|
-
/** Надгробие:
|
|
54
|
+
/** Надгробие: удалённый объект (withDeleted) или версия удаления (versions). */
|
|
50
55
|
$deleted?: true;
|
|
51
|
-
/** Глубина узла при deep() (1 — прямой потомок); в
|
|
56
|
+
/** Глубина узла при deep() (1 — прямой потомок); в предпросмотре удаления — уровень каскада (0 — цель). */
|
|
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`. `hidden` — строка-сводка
|
|
63
|
+
* предпросмотра, а не объект: скрытые правами строки замыкания (что с ними будет — `$hidden`, сколько — `$count`).
|
|
64
|
+
*/
|
|
65
|
+
$action?: 'delete' | 'unset' | 'restrict' | 'skip' | 'hidden';
|
|
66
|
+
/** Строка-сводка предпросмотра удаления (`$action: 'hidden'`): что будет со скрытыми правами строками. */
|
|
67
|
+
$hidden?: 'delete' | 'unset' | 'restrict';
|
|
68
|
+
/** Строка-сводка предпросмотра удаления (`$action: 'hidden'`): число скрытых правами строк. */
|
|
69
|
+
$count?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Почему цель `restore({ expect, partial: true })` не восстановлена: `restored` — её уже восстановили, `reissued` — id
|
|
72
|
+
* занял новый объект с тем же ключом, `changed` — после показанного удаления её меняли (удалили снова и т. п.).
|
|
73
|
+
*/
|
|
74
|
+
$why?: 'restored' | 'reissued' | 'changed';
|
|
57
75
|
}
|
|
58
76
|
/** Путь paths(): узел на каждый шаг цепочки. */
|
|
59
77
|
export type Path = Record<string, Row>;
|
|
@@ -102,7 +120,10 @@ export interface ChainMods {
|
|
|
102
120
|
/** Срез «как было на момент T» (ISO). */
|
|
103
121
|
asOf?: string;
|
|
104
122
|
after?: Cursor;
|
|
105
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Не действует: с 2.1 `withDeleted()` — модификатор своего шага (флаг шага цепочки), а не всей выборки. Поле оставлено,
|
|
125
|
+
* чтобы тип не менялся.
|
|
126
|
+
*/
|
|
106
127
|
withDeleted?: boolean;
|
|
107
128
|
/** Чтение с блокировкой строк последнего шага (только в db.begin()). */
|
|
108
129
|
forUpdate?: boolean;
|
|
@@ -121,3 +142,65 @@ export interface QueryEvent {
|
|
|
121
142
|
/** ms > slowMs. */
|
|
122
143
|
slow: boolean;
|
|
123
144
|
}
|
|
145
|
+
/** Порог логирования: события этого уровня и выше; `'off'` — ничего. */
|
|
146
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'off';
|
|
147
|
+
/** Событие логирования (каталог событий — `LOG_EVENTS`). Поля без значения в событии отсутствуют. */
|
|
148
|
+
export interface LogEvent {
|
|
149
|
+
/** Время события, ISO 8601 (UTC). */
|
|
150
|
+
ts: string;
|
|
151
|
+
level: Exclude<LogLevel, 'off'>;
|
|
152
|
+
/** Имя события: connect, read, write, delete, error, sql и др. */
|
|
153
|
+
event: string;
|
|
154
|
+
/** Операция: терминал или глагол цепочки, метод db, auth, acl, инструмент MCP. */
|
|
155
|
+
op?: string;
|
|
156
|
+
/** Длительность операции, мс (с точностью до 0,1). */
|
|
157
|
+
ms?: number;
|
|
158
|
+
/** Арендатор сессии подключения (db, db.as(), db.begin()). */
|
|
159
|
+
tenant?: string;
|
|
160
|
+
/** Аккаунт подключения: аккаунт сессии или тот, от чьего имени действует db.as(). */
|
|
161
|
+
account?: string;
|
|
162
|
+
/** Метка транзакции db.begin() или db.transaction(). */
|
|
163
|
+
tx?: string;
|
|
164
|
+
/** Цепочка в читаемом виде: `Shop({"name":"A"}).Product.limit(10).rows()`; данные записи — только именами полей. */
|
|
165
|
+
chain?: string;
|
|
166
|
+
/** Строк в ответе или затронуто. */
|
|
167
|
+
rows?: number;
|
|
168
|
+
/** Код ошибки letopis (или SQLSTATE ошибки базы) — у error. */
|
|
169
|
+
code?: string;
|
|
170
|
+
/** Ключ каталога сообщений — у error и warning. */
|
|
171
|
+
key?: string;
|
|
172
|
+
/** Текст ошибки или предупреждения. */
|
|
173
|
+
msg?: string;
|
|
174
|
+
/** Подробности события; секреты замаскированы. */
|
|
175
|
+
detail?: Record<string, unknown>;
|
|
176
|
+
/** Причина ошибки (исходная ошибка базы и т. п.) — у error. */
|
|
177
|
+
cause?: {
|
|
178
|
+
name?: string;
|
|
179
|
+
message: string;
|
|
180
|
+
code?: string;
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Логгер-функция. */
|
|
184
|
+
export type LogFn = (event: LogEvent) => unknown;
|
|
185
|
+
/** Логгер-объект: pino, console и подобные — вызов `logger[уровень](событие, событие.event)`. */
|
|
186
|
+
export interface LogObject {
|
|
187
|
+
debug?(event: LogEvent, msg: string): unknown;
|
|
188
|
+
info?(event: LogEvent, msg: string): unknown;
|
|
189
|
+
warn?(event: LogEvent, msg: string): unknown;
|
|
190
|
+
error?(event: LogEvent, msg: string): unknown;
|
|
191
|
+
log?(event: LogEvent, msg: string): unknown;
|
|
192
|
+
}
|
|
193
|
+
/** Логгер: функция или объект с методами уровней. */
|
|
194
|
+
export type Logger = LogFn | LogObject;
|
|
195
|
+
/** Опции логирования connect() и up(). */
|
|
196
|
+
export interface LogOptions {
|
|
197
|
+
/**
|
|
198
|
+
* Логгер: функция `(событие) => void` или объект с методами `debug`, `info`, `warn`, `error` (pino, console). Без него
|
|
199
|
+
* и с переменной окружения `LETOPIS_LOG=<уровень>` события идут JSON-строками в stderr.
|
|
200
|
+
*/
|
|
201
|
+
log?: Logger;
|
|
202
|
+
/** Порог: `'debug' | 'info' | 'warn' | 'error' | 'off'`, по умолчанию `'info'` (или уровень из `LETOPIS_LOG`). */
|
|
203
|
+
logLevel?: LogLevel;
|
|
204
|
+
/** Ещё имена полей, значения которых маскируются в событиях (пароли, токены и ключи маскируются всегда). */
|
|
205
|
+
redact?: readonly string[];
|
|
206
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
*/
|
|
4
4
|
/**
|
|
5
5
|
* Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
|
|
6
|
-
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии
|
|
6
|
+
* После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии (того же поколения `v2`) `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).
|
|
@@ -16,7 +21,7 @@ export const ACL_CLASS_NAMES = ['rule', 'Resource', 'member', 'Account', 'Class'
|
|
|
16
21
|
// Методы цепочки — единственный источник зарезервированных имён (план, §2.14, этап 3, п. 10)
|
|
17
22
|
// ---------------------------------------------------------------------------
|
|
18
23
|
/**
|
|
19
|
-
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и
|
|
24
|
+
* Имена методов цепочки: модификаторы, терминалы, глаголы записи и шаги роли, а также `then`. После шага цепочки эти
|
|
20
25
|
* имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
|
|
21
26
|
*/
|
|
22
27
|
export const CHAIN_METHODS = [
|
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 (база уже существует). */
|
|
@@ -120,9 +121,11 @@ export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'mig
|
|
|
120
121
|
*/
|
|
121
122
|
login?: LoginOptions;
|
|
122
123
|
}
|
|
123
|
-
/** Роль приложения up({ login }). */
|
|
124
|
+
/** Роль приложения up({ login }): `{ user, password }` (поле — `user`, не `name`). */
|
|
124
125
|
export interface LoginOptions {
|
|
126
|
+
/** Имя роли PostgreSQL для приложения, например `'shop_app'`: нет — up() её создаёт, есть — меняет пароль. */
|
|
125
127
|
user: string;
|
|
128
|
+
/** Пароль роли: войдёт в строку подключения `r.login.dsn`. */
|
|
126
129
|
password: string;
|
|
127
130
|
}
|
|
128
131
|
export interface UpResult extends InstallResult {
|
|
@@ -199,7 +202,8 @@ export interface TenantOptions {
|
|
|
199
202
|
* APIKEY, KEYSECRET, OTP, TOTP, KEY, LOCK); иначе invalid_data.
|
|
200
203
|
*/
|
|
201
204
|
kind?: string;
|
|
202
|
-
ttl
|
|
205
|
+
/** Срок сессии: как ttl у auth (число секунд, '15m', интервал PostgreSQL '15 minutes'); по умолчанию 7 суток. */
|
|
206
|
+
ttl?: string | number;
|
|
203
207
|
/** Роли членства (по умолчанию ['owner']); роли уже существующего членства повтор не меняет. */
|
|
204
208
|
roles?: string[];
|
|
205
209
|
};
|
|
@@ -221,4 +225,3 @@ export interface TenantResult {
|
|
|
221
225
|
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
222
226
|
*/
|
|
223
227
|
export declare function createTenant(sql: postgres.Sql, schema: string, opts: TenantOptions, owner?: string): Promise<TenantResult>;
|
|
224
|
-
export {};
|
package/dist/up.js
CHANGED
|
@@ -16,15 +16,16 @@ 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
|
-
import { hashPassword } from './auth.js';
|
|
22
|
+
import { hashPassword, ttlGuard, ttlOf } from './auth.js';
|
|
22
23
|
import { checkOptions, lerr, lmsg } from './errors.js';
|
|
23
24
|
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 }));
|
|
@@ -543,8 +572,15 @@ async function ensureLogin(sql, login, owner, app, dsn) {
|
|
|
543
572
|
const verb = r ? 'alter role %I login password %L' : 'create role %I login password %L';
|
|
544
573
|
const [{ q }] = await tx `select format(${verb}::text, ${login.user}::text, ${login.password}::text) as q`;
|
|
545
574
|
await tx.unsafe(q);
|
|
546
|
-
const [{ g }] = await tx `select format('grant %I to %I', ${app}::text, ${login.user}::text) as g`;
|
|
547
|
-
|
|
575
|
+
const [{ g, installer }] = await tx `select format('grant %I to %I', ${app}::text, ${login.user}::text) as g, current_user::text as installer`;
|
|
576
|
+
// PostgreSQL 16+: выдать роль может только тот, у кого на неё ADMIN OPTION (или суперпользователь) — понятный отказ
|
|
577
|
+
// вместо сырого 42501 «permission denied to grant role» (3.0.1)
|
|
578
|
+
await tx.unsafe(g).catch((e) => {
|
|
579
|
+
if (e?.code === '42501') {
|
|
580
|
+
throw lerr('boot.login_grant_denied', { installer, app, user: login.user }, { cause: e });
|
|
581
|
+
}
|
|
582
|
+
throw e;
|
|
583
|
+
});
|
|
548
584
|
return !r;
|
|
549
585
|
});
|
|
550
586
|
let url;
|
|
@@ -597,13 +633,25 @@ function checkTenant(opts) {
|
|
|
597
633
|
* пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
|
|
598
634
|
*/
|
|
599
635
|
export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
636
|
+
// admin — пул postgres(): строка DSN, подключение connect() letopis или другой объект упали бы голым TypeError на
|
|
637
|
+
// sql.begin (3.0)
|
|
638
|
+
const pool = sql;
|
|
639
|
+
if (typeof pool !== 'function' || typeof pool.begin !== 'function' || typeof pool.unsafe !== 'function') {
|
|
640
|
+
throw lerr('api.create_tenant_admin', { type: pool === null ? 'null' : typeof pool });
|
|
641
|
+
}
|
|
642
|
+
// имя схемы — как у connect() и up(): без точки — с префиксом версии (v2.salon)
|
|
643
|
+
if (typeof schema === 'string' && !schema.includes('.'))
|
|
644
|
+
schema = `${SCHEMA_VERSION}.${schema}`;
|
|
600
645
|
const roles = checkTenant(opts);
|
|
601
646
|
const S = quoteIdent(schema);
|
|
602
647
|
const tenant = v5Id(SYSTEM_ID, 'Account', [`tenant:${opts.name}`]);
|
|
603
648
|
const u = opts.user;
|
|
604
|
-
|
|
649
|
+
// логин без крайних пробелов, как у auth.setPassword() и verifyPassword() (план 2.1, A12)
|
|
650
|
+
const login = u ? (typeof u.login === 'string' ? u.login.trim() : u.login) : '';
|
|
651
|
+
const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${login}`]) : null;
|
|
652
|
+
const ttl = u ? ttlOf(u.ttl, 'createTenant') : null;
|
|
605
653
|
const hash = u ? await hashPassword(u.password) : null;
|
|
606
|
-
return sql.begin(async (tx) => {
|
|
654
|
+
return ttlGuard(sql.begin(async (tx) => {
|
|
607
655
|
// под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
|
|
608
656
|
// раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
|
|
609
657
|
await lockSchema(tx, schema);
|
|
@@ -624,20 +672,20 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
|
|
|
624
672
|
}
|
|
625
673
|
let token = null;
|
|
626
674
|
if (u && user) {
|
|
627
|
-
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login
|
|
675
|
+
if (!(await exists(user, 'Account', 'boot.tenant_user_deleted', { login }))) {
|
|
628
676
|
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
677
|
}
|
|
630
678
|
const member = (await tx.unsafe(`select ${S}.v5_id($1::uuid, 'member', array[$2::text, $1::text]) as id`, [tenant, user]))[0].id;
|
|
631
679
|
// существующее членство не трогаем: повтор с другими ролями их не меняет (их правят правкой member)
|
|
632
|
-
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login
|
|
680
|
+
if (!(await exists(member, 'member', 'boot.tenant_member_deleted', { login, name: opts.name }))) {
|
|
633
681
|
await tx.unsafe(`select ${S}.sys_create($1::uuid, 'member', $2::uuid, $3::jsonb, $4::jsonb)`, [member, tenant, { User: user, Tenant: tenant }, { roles }]);
|
|
634
682
|
}
|
|
635
683
|
const kind = u.kind ?? 'PASSWORD';
|
|
636
684
|
await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta)
|
|
637
685
|
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,
|
|
639
|
-
token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user,
|
|
686
|
+
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]);
|
|
687
|
+
token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user, ttl ?? '7 days', { tenant }]))[0].t;
|
|
640
688
|
}
|
|
641
689
|
return { tenant, user, token, created };
|
|
642
|
-
});
|
|
690
|
+
}), u?.ttl, 'createTenant');
|
|
643
691
|
}
|
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;
|
package/dist/watch.js
CHANGED
|
@@ -66,6 +66,8 @@ export class Subscription {
|
|
|
66
66
|
start = null;
|
|
67
67
|
/** Вызовы next() — по очереди: параллельные получают разные события (доработки после точки Б). */
|
|
68
68
|
queue = Promise.resolve();
|
|
69
|
+
/** Выдано событий — для watch.stop. */
|
|
70
|
+
delivered = 0;
|
|
69
71
|
constructor(ctx, opts) {
|
|
70
72
|
this.ctx = ctx;
|
|
71
73
|
this.opts = opts;
|
|
@@ -95,6 +97,10 @@ export class Subscription {
|
|
|
95
97
|
this.wake?.();
|
|
96
98
|
}, this.pollMs);
|
|
97
99
|
this.timer.unref?.();
|
|
100
|
+
if (ctx.log?.wants('watch.start')) {
|
|
101
|
+
ctx.log.emit('watch.start', { op: 'watch', detail: { from: opts.from ?? 'now', classes: ctx.classes, batch: this.batch,
|
|
102
|
+
pollMs: this.pollMs, signals: Boolean(ctx.onSignal) } });
|
|
103
|
+
}
|
|
98
104
|
}
|
|
99
105
|
/** Курсор подписки: позиция после последнего выданного события (или начальная точка) — с неё продолжают после перезапуска. */
|
|
100
106
|
get cursor() {
|
|
@@ -128,6 +134,8 @@ export class Subscription {
|
|
|
128
134
|
// пока события пачки не выданы, курсор подписки — позиция до пачки
|
|
129
135
|
if (rows.length)
|
|
130
136
|
this.given = encode(p);
|
|
137
|
+
if (rows.length && this.ctx.log?.wants('watch.fetch'))
|
|
138
|
+
this.ctx.log.emit('watch.fetch', { op: 'watch', rows: rows.length });
|
|
131
139
|
for (const r of rows) {
|
|
132
140
|
const row = toRow(r.row);
|
|
133
141
|
const snap = keepSnap(p.snap, r.tx);
|
|
@@ -153,6 +161,7 @@ export class Subscription {
|
|
|
153
161
|
if (this.buf.length) {
|
|
154
162
|
const ev = this.buf.shift();
|
|
155
163
|
this.given = ev.cursor;
|
|
164
|
+
this.delivered++;
|
|
156
165
|
return { value: ev, done: false };
|
|
157
166
|
}
|
|
158
167
|
if (this.closed)
|
|
@@ -167,6 +176,7 @@ export class Subscription {
|
|
|
167
176
|
}
|
|
168
177
|
catch (e) {
|
|
169
178
|
this.failure = e;
|
|
179
|
+
this.ctx.log?.error(e, { op: 'watch' });
|
|
170
180
|
this.close();
|
|
171
181
|
throw e;
|
|
172
182
|
}
|
|
@@ -209,6 +219,9 @@ export class Subscription {
|
|
|
209
219
|
if (this.closed)
|
|
210
220
|
return;
|
|
211
221
|
this.closed = true;
|
|
222
|
+
if (this.ctx.log?.wants('watch.stop')) {
|
|
223
|
+
this.ctx.log.emit('watch.stop', { op: 'watch', rows: this.delivered, detail: { cursor: this.cursor, ...(this.failure ? { failed: true } : {}) } });
|
|
224
|
+
}
|
|
212
225
|
this.off?.();
|
|
213
226
|
this.ctx.onClose?.(this);
|
|
214
227
|
if (this.timer)
|