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.
Files changed (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. 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`); схему отставшей ревизии 2.x `up()` обновляет только с
6
+ * После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии (того же поколения `v2`) `up()` обновляет только с
7
7
  * `upgrade: true`, а `connect()` о ней предупреждает.
8
8
  */
9
- export declare const DDL_REVISION = 8;
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
- * Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then`. После шага цепочки эти
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
- /** Надгробие: удалённая сущность (withDeleted) или версия удаления (versions). */
54
+ /** Надгробие: удалённый объект (withDeleted) или версия удаления (versions). */
50
55
  $deleted?: true;
51
- /** Глубина узла при deep() (1 — прямой потомок); в превью удаления — уровень каскада (0 — цель). */
56
+ /** Глубина узла при deep() (1 — прямой потомок); в предпросмотре удаления — уровень каскада (0 — цель). */
52
57
  $depth?: number;
53
58
  /** Что сделал upsert. */
54
59
  $upsert?: 'created' | 'updated' | 'unchanged';
55
- /** Превью удаления (delete() без confirm): удалится, снимется конец или помешает (restrict). */
56
- $action?: 'delete' | 'unset' | 'restrict';
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`); схему отставшей ревизии 2.x `up()` обновляет только с
6
+ * После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии (того же поколения `v2`) `up()` обновляет только с
7
7
  * `upgrade: true`, а `connect()` о ней предупреждает.
8
8
  */
9
- export const DDL_REVISION = 8;
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
- * Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then`. После шага цепочки эти
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 declare const SCHEMA_VERSION = "v2";
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?: string;
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 const SCHEMA_VERSION = 'v2';
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
- const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
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
- if (!opts.quiet)
440
- for (const w of warnings)
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
- if (!opts.quiet)
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
- if (!opts.quiet)
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 ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
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
- await tx.unsafe(g);
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
- const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
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: u.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: u.login, name: opts.name }))) {
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, u.login, hash]);
639
- token = (await tx.unsafe(`select ${S}.new_session($1::uuid, $2::interval, $3::jsonb) as t`, [user, u.ttl ?? '7 days', { tenant }]))[0].t;
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
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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 : lmsg('model.issue_enum')) });
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
- extra: (v) => { if (typeof v !== 'string')
181
- return null; const n = [...v].length; return n < min || n > max ? lmsg('model.issue_string_length') : null; } });
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
- out.push({ path, keyword: 'additionalProperties', select: sel, test: (v) => !isObj(v) || Object.keys(v).every((k) => known.includes(k)) });
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 if (!nodes.every((n) => r.test(n))) {
262
- issues.push({ path: r.path, keyword: r.keyword, message: lmsg('model.issue_rule', { keyword: r.keyword }) });
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)