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.
Files changed (75) hide show
  1. package/AGENT-CHEATSHEET.md +484 -414
  2. package/CHANGELOG.md +321 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +786 -183
  6. package/api-contract.json +381 -0
  7. package/dist/acl.d.ts +3 -0
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +2 -1
  10. package/dist/admin.js +6 -4
  11. package/dist/auth.d.ts +18 -3
  12. package/dist/auth.js +75 -6
  13. package/dist/chain.d.ts +38 -17
  14. package/dist/chain.js +434 -63
  15. package/dist/cli.js +28 -6
  16. package/dist/errors.d.ts +19 -1
  17. package/dist/errors.js +46 -6
  18. package/dist/index.d.ts +10 -4
  19. package/dist/index.js +136 -46
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +9 -5
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +633 -0
  25. package/dist/mcp.js +26 -11
  26. package/dist/messages/api.d.ts +66 -0
  27. package/dist/messages/api.js +90 -1
  28. package/dist/messages/boot.js +4 -4
  29. package/dist/messages/codes.js +4 -4
  30. package/dist/messages/index.d.ts +5 -2
  31. package/dist/messages/index.js +5 -2
  32. package/dist/messages/log.d.ts +229 -0
  33. package/dist/messages/log.js +102 -0
  34. package/dist/messages/model.d.ts +24 -1
  35. package/dist/messages/model.js +34 -3
  36. package/dist/messages/query.d.ts +10 -0
  37. package/dist/messages/query.js +13 -0
  38. package/dist/messages/sql-core.d.ts +15 -1
  39. package/dist/messages/sql-core.js +20 -3
  40. package/dist/messages/sql-time.d.ts +15 -0
  41. package/dist/messages/sql-time.js +23 -0
  42. package/dist/messages/write.d.ts +47 -0
  43. package/dist/messages/write.js +63 -0
  44. package/dist/model.d.ts +4 -0
  45. package/dist/model.js +23 -5
  46. package/dist/sql.d.ts +2 -0
  47. package/dist/sql.js +77 -25
  48. package/dist/sync.d.ts +1 -1
  49. package/dist/sync.js +50 -0
  50. package/dist/tx.d.ts +10 -1
  51. package/dist/tx.js +44 -2
  52. package/dist/typed.d.ts +14 -10
  53. package/dist/types.d.ts +82 -4
  54. package/dist/types.js +6 -1
  55. package/dist/up.d.ts +3 -3
  56. package/dist/up.js +57 -17
  57. package/dist/uuid.d.ts +8 -0
  58. package/dist/uuid.js +9 -1
  59. package/dist/validate.js +24 -7
  60. package/dist/watch.d.ts +5 -0
  61. package/dist/watch.js +13 -0
  62. package/dist/write.d.ts +78 -1
  63. package/dist/write.js +357 -39
  64. package/llms.txt +37 -17
  65. package/package.json +12 -6
  66. package/sql/20-context.sql +11 -3
  67. package/sql/30-validate.sql +51 -10
  68. package/sql/40-class.sql +21 -1
  69. package/sql/80-auth.sql +88 -17
  70. package/sql/90-time.sql +34 -6
  71. package/sql/99-revision.sql +5 -1
  72. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  73. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  74. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  75. 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(() => undefined, (e) => {
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
- /** С `{ confirm: true }` без фильтра, id, `tags()`, `owner()` и `limit()` у шагов — `invalid_query`; всех — `{ all: true }`. */
294
- delete(opts?: {
295
- confirm?: boolean;
296
- all?: boolean;
297
- reason?: string;
298
- }): TypedChain<C, M>;
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
- restore(opts?: {
301
- reason?: string;
302
- }): TypedChain<C, M>;
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 = 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).
@@ -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
- /** Превью удаления (delete() без confirm): удалится, снимется конец или помешает (restrict). */
56
- $action?: 'delete' | 'unset' | 'restrict';
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 = 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).
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 (база уже существует). */
@@ -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 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 }));
@@ -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
- const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
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: u.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: u.login, name: opts.name }))) {
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, u.login, hash]);
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
- 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;