letopis 1.4.0 → 1.4.2

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 (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  9. package/dist/auth.d.ts +61 -17
  10. package/dist/auth.js +16 -2
  11. package/dist/cache.d.ts +5 -1
  12. package/dist/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/tx.d.ts CHANGED
@@ -31,6 +31,21 @@ export declare function isTransient(e: unknown): boolean;
31
31
  export declare function isLogConflict(e: unknown): boolean;
32
32
  /** Обрыв связи: сокет закрыт или серверный процесс завершён (FATAL) — исход отправленного неизвестен. */
33
33
  export declare function isConnectionError(e: unknown): boolean;
34
+ /**
35
+ * Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
36
+ * уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
37
+ */
38
+ export declare function rollbackAfter(conn: {
39
+ unsafe(text: string): Promise<unknown>;
40
+ }, err: unknown): Promise<void>;
41
+ /**
42
+ * Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
43
+ * соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
44
+ * писала бы в сокет null — необработанный TypeError роняет процесс.
45
+ */
46
+ export declare function release(conn: {
47
+ release(): void;
48
+ }, err: unknown): void;
34
49
  export declare const RETRIES = 3;
35
50
  export declare const retryDelay: (attempt: number) => Promise<void>;
36
51
  /** Нотификатор процесса: копит нагрузки сигналов и отправляет их пачкой раз в интервал. */
package/dist/tx.js CHANGED
@@ -17,12 +17,21 @@ export function isConnectionError(e) {
17
17
  const code = String(err?.code ?? '');
18
18
  return code.startsWith('CONNECTION_') || ['ECONNRESET', 'EPIPE', 'ECONNREFUSED', 'ETIMEDOUT', 'ECONNABORTED'].includes(code);
19
19
  }
20
+ /**
21
+ * Откатить транзакцию на зарезервированном соединении после ошибки: после обрыва в него не пишут (postgres.js 3.4
22
+ * уронил бы процесс необработанным TypeError), иначе — rollback, ошибка которого не важна (план 2.0, 7.4 №7).
23
+ */
24
+ export async function rollbackAfter(conn, err) {
25
+ if (isConnectionError(err))
26
+ return;
27
+ await conn.unsafe('rollback').catch(() => { });
28
+ }
20
29
  /**
21
30
  * Вернуть зарезервированное соединение в пул. После обрыва сокета пул postgres.js уже перевёл
22
31
  * соединение в закрытые (onclose); release() вернул бы его в открытые, и следующая операция
23
32
  * писала бы в сокет null — необработанный TypeError роняет процесс.
24
33
  */
25
- function release(conn, err) {
34
+ export function release(conn, err) {
26
35
  const e = err;
27
36
  if (e && !e.severity && isConnectionError(e))
28
37
  return;
@@ -233,8 +242,8 @@ export class ExplicitTx {
233
242
  ctx.passes.set(ctx, (await passSql(conn, ctx))[0]?.p);
234
243
  }
235
244
  catch (e) {
236
- await conn.unsafe('rollback').catch(() => { });
237
- conn.release();
245
+ await rollbackAfter(conn, e);
246
+ release(conn, e);
238
247
  throw fromDbError(e);
239
248
  }
240
249
  return new ExplicitTx(ctx, conn);
@@ -282,7 +291,16 @@ export class ExplicitTx {
282
291
  let changed = null;
283
292
  try {
284
293
  // чтение letopis.changed и COMMIT — одним конвейером (как в runTx); обрыв — исход неизвестен
285
- const [rows, res] = await Promise.all([this.conn.unsafe(`select current_setting('letopis.changed', true) as changed`), this.conn.unsafe('commit')]);
294
+ const read = this.conn.unsafe(`select current_setting('letopis.changed', true) as changed`);
295
+ const commit = this.conn.unsafe('commit');
296
+ const [rows, res] = (await Promise.all([read, commit]).catch(async (e) => {
297
+ // транзакцию уже прервала ошибка, которую код внутри db.begin() поймал и проглотил: чтение получает 25P02,
298
+ // а COMMIT того же конвейера отвечает ROLLBACK — это tx_rolled_back, а не сырая ошибка PostgreSQL
299
+ const tail = e.code === '25P02' ? await commit.catch(() => null) : null;
300
+ if (tail?.command === 'ROLLBACK')
301
+ return [[], tail];
302
+ throw e;
303
+ }));
286
304
  changed = rows[0]?.changed ?? null;
287
305
  if (res.command === 'ROLLBACK') {
288
306
  this.failure = new LetopisError('tx_rolled_back', 'commit: PostgreSQL откатил транзакцию (в ней была ошибка)');
package/dist/types.d.ts CHANGED
@@ -1,23 +1,30 @@
1
1
  /**
2
2
  * Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
3
3
  */
4
- /** Ревизия движка в базе: штамп в lib/sql/99-revision.sql должен совпадать (этап 1). */
5
- export declare const DDL_REVISION = 6;
4
+ /**
5
+ * Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
6
+ * После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии `up()` обновляет только с `upgrade: true`,
7
+ * а `connect()` о ней предупреждает.
8
+ */
9
+ export declare const DDL_REVISION = 7;
6
10
  /**
7
11
  * Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
8
12
  * сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
9
13
  */
10
14
  export declare const ACL_CLASS_NAMES: readonly string[];
11
- /** Методы цепочки: терминалы, модификаторы, глаголы записи (этап 4), охранники снесённого API. */
15
+ /**
16
+ * Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then` и снятые методы прежних
17
+ * версий. После шага цепочки эти имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
18
+ */
12
19
  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", "anonymize", "reclass", "restore", "purge", "alias", "tags", "owner", "forUpdate", "cache", "rekey", "run", "execute", "account", "diff", "blame"];
13
20
  /** Свойства корня db (и транзакции db.begin()). */
14
21
  export declare const DB_METHODS: readonly ["as", "begin", "commit", "rollback", "lock", "batch", "watch", "close", "registry", "sql", "load", "verify", "maintain", "reset", "describe", "auth", "acl", "idOf", "refresh", "transaction", "flushSignals", "schema", "token", "tenant", "reloadSchema", "accounts", "credentials", "resources", "rules", "compact", "trimHistory", "rekeyClass", "cache", "projector", "rebuildAggregates"];
15
22
  /** Свойства батча db.batch(name). */
16
23
  export declare const BATCH_METHODS: readonly ["discard", "size"];
17
24
  /**
18
- * Зарезервированные имена: Proxy разбирает их раньше классов, поэтому класс с таким именем
19
- * недостижим как шаг. Установщик подставляет список в функцию базы reserved_names(), которая
20
- * отклоняет связи с такими именами; совпадение со switch Proxy проверяет check-docs.
25
+ * Имена методов цепочки, корня `db` и батча. Там, где имя занято методом, библиотека не ищет его среди классов, и шаг с
26
+ * таким именем до класса не дойдёт; класс-связь (`link`) с таким именем база не создаст (`invalid_class`). Не давайте
27
+ * классам эти имена.
21
28
  */
22
29
  export declare const RESERVED_CLASS_NAMES: readonly string[];
23
30
  /** Строка ответа: текущая версия (или версия журнала для asOf, versions, withDeleted). */
@@ -78,10 +85,17 @@ export type Filter = string | string[] | Op | {
78
85
  } | {
79
86
  [field: string]: FieldFilter | undefined;
80
87
  };
81
- /** Курсор keyset-пагинации: значение поля сортировки и id последней строки страницы. */
88
+ /**
89
+ * Курсор постраничного вывода для `after()`: значение поля сортировки и id последней строки страницы. Равные по полю
90
+ * строки разводят `rev` (версии в `versions()`) и `path` (id всех узлов пути в `paths()`); `cursorOf` заполняет их сам.
91
+ */
82
92
  export interface Cursor {
83
93
  v: string | number | boolean | null;
84
94
  id: string;
95
+ /** Номер версии строки: в `versions()` отделяет версии одного объекта с равным полем сортировки. */
96
+ rev?: number;
97
+ /** Узлы пути из `paths()`: ключ узла → id; отделяет пути с общим последним узлом. */
98
+ path?: Record<string, string>;
85
99
  }
86
100
  /** Модификаторы выборки цепочки. */
87
101
  export interface ChainMods {
package/dist/types.js CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Общие типы letopis 1.0. Файл наполняется по этапам плана (reports/PLAN-LETOPIS-1.0.md).
3
3
  */
4
- /** Ревизия движка в базе: штамп в lib/sql/99-revision.sql должен совпадать (этап 1). */
5
- export const DDL_REVISION = 6;
4
+ /**
5
+ * Ревизия схемы, которую знает эта версия пакета, — номер версии SQL-части letopis (таблиц и функций в схеме установки).
6
+ * После `up()` у схемы та же ревизия (`r.revision`); схему отставшей ревизии `up()` обновляет только с `upgrade: true`,
7
+ * а `connect()` о ней предупреждает.
8
+ */
9
+ export const DDL_REVISION = 7;
6
10
  /**
7
11
  * Классы, строки которых меняют права и схему: их запись повышает счётчик прав (загрузчик), а сигнал
8
12
  * сбрасывает весь кэш результатов и пропуска прав. Тот же список — в триггерах базы (60-write.sql).
@@ -11,7 +15,10 @@ export const ACL_CLASS_NAMES = ['rule', 'Resource', 'member', 'Account', 'Class'
11
15
  // ---------------------------------------------------------------------------
12
16
  // Методы цепочки — единственный источник зарезервированных имён (план, §2.14, этап 3, п. 10)
13
17
  // ---------------------------------------------------------------------------
14
- /** Методы цепочки: терминалы, модификаторы, глаголы записи (этап 4), охранники снесённого API. */
18
+ /**
19
+ * Имена методов цепочки: модификаторы, терминалы, глаголы записи и слоты концов, а также `then` и снятые методы прежних
20
+ * версий. После шага цепочки эти имена библиотека не ищет среди классов; все они входят в `RESERVED_CLASS_NAMES`.
21
+ */
15
22
  export const CHAIN_METHODS = [
16
23
  'then', 'entity', 'paths', 'rows', 'first', 'ids', 'count', 'limit', 'offset', 'sort', 'asOf', 'withDeleted',
17
24
  'deep', 'exact', 'sum', 'avg', 'min', 'max', 'countBy', 'after', 'versions', 'create', 'update', 'upsert', 'set',
@@ -28,9 +35,9 @@ export const DB_METHODS = [
28
35
  /** Свойства батча db.batch(name). */
29
36
  export const BATCH_METHODS = ['discard', 'size'];
30
37
  /**
31
- * Зарезервированные имена: Proxy разбирает их раньше классов, поэтому класс с таким именем
32
- * недостижим как шаг. Установщик подставляет список в функцию базы reserved_names(), которая
33
- * отклоняет связи с такими именами; совпадение со switch Proxy проверяет check-docs.
38
+ * Имена методов цепочки, корня `db` и батча. Там, где имя занято методом, библиотека не ищет его среди классов, и шаг с
39
+ * таким именем до класса не дойдёт; класс-связь (`link`) с таким именем база не создаст (`invalid_class`). Не давайте
40
+ * классам эти имена.
34
41
  */
35
42
  export const RESERVED_CLASS_NAMES = [...new Set([...CHAIN_METHODS, ...DB_METHODS, ...BATCH_METHODS])];
36
43
  /** Метка операторов фильтра (символ не пересекается с данными). */
package/dist/up.d.ts CHANGED
@@ -8,28 +8,36 @@ export interface InstallOptions {
8
8
  schema: string;
9
9
  owner?: string;
10
10
  app?: string;
11
- /** Константа установки: разрешён ли reset (план, §2.2). По умолчанию выключен. */
11
+ /**
12
+ * Разрешить `db.reset()` и `maintain({ now })` — для тестовых установок и стендов; по умолчанию запрещено. Значение
13
+ * задаётся при установке и хранится в схеме: каждый `up()` записывает его заново (план, §2.2).
14
+ */
12
15
  allowReset?: boolean;
13
16
  /**
14
- * Константа установки: политика хранения истории для классов без своей (план, §2.11), например
15
- * `{ all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever' }`.
16
- * Не задана — история хранится вся.
17
+ * Политика хранения истории для классов без своей, например
18
+ * `{ all: '1 day', daily: '1 week', weekly: '1 month', monthly: '1 year', yearly: 'forever' }`; не задана — история
19
+ * хранится вся. Значение задаётся при установке и хранится в схеме: каждый `up()` записывает его заново (план, §2.11).
17
20
  */
18
21
  history?: HistoryPolicy;
19
22
  /** Номер последнего накатываемого файла — для пошаговых тестов. */
20
23
  upTo?: number;
21
24
  /** Сиды после ядра: имена lib/sql/seed.<имя>.sql (например 'booking') или пути к своим файлам .sql. */
22
25
  seeds?: string[];
23
- /** Обновление (upgrade): ревизия схемы до наката — миграции lib/sql/migrate/ с номером выше неё. */
26
+ /**
27
+ * Обновление (upgrade): ревизия схемы до наката — миграции lib/sql/migrate/ с номером выше неё. Если под блокировкой
28
+ * схемы видна более новая ревизия (её уже обновил одновременный up()), миграции считаются от неё.
29
+ */
24
30
  migrateFrom?: number;
31
+ /** Удалить схему и поставить заново (up({ fresh })) — под той же блокировкой схемы, что и накат. ДАННЫЕ ТЕРЯЮТСЯ. */
32
+ fresh?: boolean;
25
33
  /** Каталог миграций (по умолчанию lib/sql/migrate). */
26
34
  migrationsDir?: string;
27
35
  /** Свой ключ сервиса: зарегистрировать, если такого ещё нет (вместо выпуска случайного). */
28
36
  serviceKey?: string;
29
37
  /**
30
- * Режим сигналов NOTIFY (план 1.1, §2.2): 'class' (по умолчанию, как в 1.0) — имена классов; 'tenant' —
31
- * имя класса и токен арендатора; 'opaque' — только токены; 'none' — сигналов нет. Режим хранится в
32
- * схеме: накат без опции его не меняет, reset({ all }) — сохраняет.
38
+ * Что видно в сигналах базы (`NOTIFY`): `'class'` (у новой схемы по умолчанию) — имя класса; `'tenant'` — имя класса и
39
+ * псевдоним арендатора (токен); `'opaque'` — только псевдонимы класса и арендатора; `'none'` — сигналов нет.
40
+ * Режим хранится в схеме: `up()` без опции его не меняет, `reset({ all })` сохраняет (план 1.1, §2.2).
33
41
  */
34
42
  signals?: SignalMode;
35
43
  }
@@ -47,7 +55,11 @@ export interface InstallResult {
47
55
  schema: string;
48
56
  owner: string;
49
57
  app: string;
50
- /** Ключ сервисного аккаунта; выдаётся только при первой установке (или зарегистрированный свой), дальше — null. */
58
+ /**
59
+ * Ключ API сервисного аккаунта, выпущенный или зарегистрированный этим накатом, иначе `null`. Без опции `serviceKey`
60
+ * новый ключ выпускается на любом накате, где в схеме не осталось ни одного неотозванного ключа API, — не только при
61
+ * первой установке; свой `serviceKey` регистрируется и возвращается, если неотозванного ключа с таким значением ещё нет.
62
+ */
51
63
  serviceKey: string | null;
52
64
  /** Применённые миграции. */
53
65
  migrations: string[];
@@ -75,7 +87,9 @@ export declare function compareVersions(a: string, b: string): number;
75
87
  */
76
88
  export declare function stampDrift(schema: string, comment: string): Promise<string | null>;
77
89
  export declare function substitute(text: string, vars: Record<string, string>): string;
78
- export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult>;
90
+ export declare function install(sql: postgres.Sql, opts: InstallOptions): Promise<InstallResult & {
91
+ found: number | null;
92
+ }>;
79
93
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
80
94
  export declare function migrationFiles(dir: string, from: number): Promise<string[]>;
81
95
  export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'migrateFrom'> {
@@ -94,11 +108,16 @@ export interface UpOptions extends Omit<InstallOptions, 'schema' | 'upTo' | 'mig
94
108
  /** Сколько ждать базу, мс (по умолчанию 60 000). */
95
109
  waitTimeoutMs?: number;
96
110
  /**
97
- * База для разработки без PostgreSQL (решение 6 точки А): PGlite с pgcrypto через pglite-socket;
98
- * одно соединение — connect({ dsn: r.pglite.dsn, listen: false, max: 1 }); не для гонок и CI.
111
+ * Запустить PGlite — PostgreSQL прямо в процессе Node — для разработки без своего сервера (решение 6 точки А);
112
+ * подключение к нему — `connect({ dsn: r.pglite.dsn, schema: r.schema, listen: false, max: 1, allowBypassRls: true })`.
113
+ * Соединение у PGlite одно, поэтому одновременные транзакции и гонки на нём не проверить: для таких тестов и в CI нужен
114
+ * настоящий PostgreSQL.
99
115
  */
100
116
  pglite?: boolean | PgliteOptions;
101
- /** Арендатор и его первый пользователь — без своего сида (createTenant). */
117
+ /**
118
+ * Арендатор и его первый пользователь: после установки `up()` заводит их сам, как `createTenant()` с этими опциями, и
119
+ * кладёт итог в `r.tenant` — свой сид для этого не нужен.
120
+ */
102
121
  tenant?: TenantOptions;
103
122
  }
104
123
  export interface UpResult extends InstallResult {
@@ -134,14 +153,18 @@ export declare function ensureDatabase(dsn: string, log?: (m: string) => void):
134
153
  */
135
154
  export declare function checkServer(versionNum: number, encoding: string): string[];
136
155
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
137
- export declare function schemaRevision(sql: postgres.Sql, schema: string): Promise<number | null>;
156
+ export declare function schemaRevision(sql: Pick<postgres.Sql, 'unsafe'>, schema: string): Promise<number | null>;
138
157
  /**
139
- * Поставить или обновить установку letopis. Схема есть и её ревизия совпадает — повторный накат
140
- * (идемпотентный); ревизия отстала — только с upgrade: true; новее библиотеки — отказ.
158
+ * Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
159
+ * повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
160
+ * Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
141
161
  */
142
162
  export declare function up(opts: UpOptions): Promise<UpResult>;
143
163
  export interface TenantOptions {
144
- /** Имя арендатора (аккаунт в System; id — v5 от «tenant:<имя>», как у letopis import --tenant). */
164
+ /**
165
+ * Имя арендатора — его аккаунта в System. Из имени вычисляется id арендатора, поэтому одно имя — всегда один и тот же
166
+ * арендатор (так же его находит `letopis import --tenant`).
167
+ */
145
168
  name: string;
146
169
  /** Правило арендатора «аутентифицированным можно всё» (READ, WRITE, DELETE; по умолчанию да). */
147
170
  grantAll?: boolean;
@@ -150,6 +173,10 @@ export interface TenantOptions {
150
173
  name: string;
151
174
  login: string;
152
175
  password: string;
176
+ /**
177
+ * Вид пароля (по умолчанию PASSWORD) — как у auth.setPassword: `^[A-Z][A-Z0-9_]{1,31}$`, не служебный вид (SESSION,
178
+ * APIKEY, KEYSECRET, OTP, TOTP, KEY, PROJECTOR, LOCK); иначе invalid_data.
179
+ */
153
180
  kind?: string;
154
181
  ttl?: string;
155
182
  /** Роли членства (по умолчанию ['owner']); роли уже существующего членства повтор не меняет. */
@@ -167,14 +194,10 @@ export interface TenantResult {
167
194
  created: boolean;
168
195
  }
169
196
  /**
170
- * Арендатор и его первый пользователь — то, что раньше делал свой сид функциями владельца
171
- * (sys_create, sys_grant_all). Нужно администраторское подключение (роль может стать владельцем);
172
- * всё — одной транзакцией от владельца. Повтор с тем же именем и логином ничего не дублирует:
173
- * id — v5 от имени и логина, существующие строки пропускаются (пароль и роли членства не меняются),
174
- * сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
175
- * журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
176
- * что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
177
- * версию 1 — её занимает история; вернуть строку — restore() цепочкой.
197
+ * Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
198
+ * роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
199
+ * меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
200
+ * пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
178
201
  */
179
202
  export declare function createTenant(sql: postgres.Sql, schema: string, opts: TenantOptions, owner?: string): Promise<TenantResult>;
180
203
  export {};
package/dist/up.js CHANGED
@@ -24,6 +24,13 @@ import { v5Id } from './uuid.js';
24
24
  import { SIGNAL_MODES } from './signals.js';
25
25
  export const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
26
26
  export const SCHEMA_VERSION = 'v2';
27
+ /**
28
+ * Advisory-ключ наката одной схемы: пара int4, как у общей части установки (`hashtext('install')`), — имя схемы
29
+ * всегда с точкой (`v2.имя`), поэтому со словом install ключи не совпадут.
30
+ */
31
+ async function lockSchema(tx, schema) {
32
+ await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext($1))`, [schema]);
33
+ }
27
34
  const SQL_DIR = fileURLToPath(new URL('../sql/', import.meta.url));
28
35
  const MIGRATE_DIR = join(SQL_DIR, 'migrate');
29
36
  const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -92,7 +99,7 @@ export async function stampDrift(schema, comment) {
92
99
  return `letopis: схему ${schema} обновила letopis ${was}, библиотека старее (${code.version}) — обновите пакет: up() этой версией вернул бы прежние функции`;
93
100
  }
94
101
  const by = !was ? 'версия до 1.4.0' : was === code.version ? `другая сборка letopis ${was}` : `letopis ${was}`;
95
- return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: накатите up({ schema: '${schema}' })`;
102
+ return `letopis: функции схемы ${schema} поставила ${by}, библиотека — ${code.version}: обновите схему — вызовите up({ schema: '${schema}' })`;
96
103
  }
97
104
  export function substitute(text, vars) {
98
105
  return text.replace(/@([A-Z_]+)@/g, (m, k) => {
@@ -115,23 +122,45 @@ export async function install(sql, opts) {
115
122
  const [{ v }] = await sql `select current_setting('server_version_num') as v`;
116
123
  if (Number(v) < 180000)
117
124
  throw new Error(`letopis.up: нужен PostgreSQL 18 или новее, а сервер — ${v}`);
118
- // Роли — общие для кластера; создаются идемпотентно.
119
- for (const r of [owner, app]) {
120
- await sql.unsafe(`do $$ begin
121
- if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
122
- create role ${quoteIdent(r)} nologin;
123
- end if;
124
- exception when duplicate_object then null; end $$`);
125
- }
126
- // Установщик получает права владельца только на время set role (план, §2.9).
127
- await sql.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`).catch((e) => {
128
- if (e.code !== '0LP01')
129
- throw e; // уже выдано циклом — нет
125
+ // Общая часть установки — роли, членство установщика, pgcrypto и право владельца на схему pgcrypto — одна на все
126
+ // установки базы, а роли — на весь кластер. Параллельные up() разных схем меняли её одновременно: grant на схему
127
+ // pgcrypto переписывает её строку pg_namespace, даже если право уже есть, и второй такой grant падал «tuple
128
+ // concurrently updated» (XX000); на чистой базе второй create role или create extension упал бы на уникальном индексе
129
+ // каталога. Поэтому общая часть — отдельная транзакция под advisory-блокировкой: другой up() ждёт её фиксации и
130
+ // видит готовые объекты. Строка-замок, как у внутренних блокировок движка (60-write.sql), не годится: таблицы secret
131
+ // до установки нет, и у каждой схемы она своя — разные установки друг друга не увидели бы. Advisory-ключ может занять
132
+ // любая роль базы (ревью безопасности №2 и №3), но так посторонний задержит только up() — операцию администратора, а
133
+ // не запись; кто держит ключ, видно в pg_locks. Ключ — пара int4: это пространство ключей отдельно от lock() явной
134
+ // транзакции (int8). Блокировка живёт до конца транзакции, обрыв соединения её снимает.
135
+ // Advisory-блокировка действует в пределах базы, а роли общие для кластера: up() в другой базе может создать роль
136
+ // или членство одновременно с этим, тогда здесь unique_violation (23505) — роль или членство уже есть.
137
+ const nsp = await sql.begin(async (tx) => {
138
+ await tx.unsafe(`select pg_advisory_xact_lock(hashtext('letopis'), hashtext('install'))`);
139
+ for (const r of [owner, app]) {
140
+ await tx.unsafe(`do $$ begin
141
+ if not exists (select 1 from pg_roles where rolname = ${quoteLiteral(r)}) then
142
+ create role ${quoteIdent(r)} nologin;
143
+ end if;
144
+ exception when duplicate_object or unique_violation then null; end $$`);
145
+ }
146
+ // Установщик получает права владельца только на время set role (план, §2.9). Отказ гранта откатывает только
147
+ // точку сохранения, а не всю транзакцию.
148
+ await tx.savepoint((sp) => sp.unsafe(`grant ${quoteIdent(owner)} to current_user with inherit false, set true`))
149
+ .catch((e) => {
150
+ // роль владельца создал кто-то другой, а у установщика нет на неё ADMIN — понятная ошибка вместо сырой 42501
151
+ // (план 2.0, 7.5 №3)
152
+ if (e.code === '42501') {
153
+ throw Object.assign(new Error(`letopis.up: роль ${owner} уже есть, а у установщика нет на неё ADMIN OPTION — установку ведёт роль, создавшая ${owner}, или роль с этим правом (grant ${owner} to <установщик> with admin option)`), { cause: e });
154
+ }
155
+ if (e.code !== '0LP01' && e.code !== '23505')
156
+ throw e; // уже выдано циклом — нет; 23505 — выдал up() другой базы
157
+ });
158
+ await tx.unsafe(`create extension if not exists pgcrypto`);
159
+ const [{ nsp }] = await tx.unsafe(`
160
+ select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`);
161
+ await tx.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
162
+ return nsp;
130
163
  });
131
- await sql.unsafe(`create extension if not exists pgcrypto`);
132
- const [{ nsp }] = await sql `
133
- select n.nspname as nsp from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pgcrypto'`;
134
- await sql.unsafe(`grant usage on schema ${quoteIdent(nsp)} to ${quoteIdent(owner)}`);
135
164
  const vars = {
136
165
  SCHEMA: quoteIdent(opts.schema),
137
166
  SCHEMA_NAME: opts.schema,
@@ -152,13 +181,27 @@ export async function install(sql, opts) {
152
181
  const st = await engineStamp();
153
182
  vars.ENGINE_VERSION = st.version;
154
183
  vars.ENGINE_SQL = st.sql;
155
- // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
156
- await sql.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
157
184
  const files = await engineFiles(opts.upTo);
158
- const migrations = opts.migrateFrom === undefined ? [] : await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, opts.migrateFrom);
185
+ let migrations = [];
186
+ let found = null;
159
187
  let serviceKey = null;
160
188
  let signals = 'class';
161
189
  await sql.begin(async (tx) => {
190
+ // Накат схемы — под advisory-блокировкой самой схемы. Несколько экземпляров сервиса зовут up() одной схемы при
191
+ // запуске разом: без блокировки второй create schema падал на уникальном индексе каталога (23505), а второй
192
+ // create or replace function или comment — «tuple concurrently updated» (XX000). С ней следующий up() ждёт, пока
193
+ // предыдущий зафиксирует накат, и делает повторный накат по готовой схеме. Поэтому и fresh, и ревизия, от которой
194
+ // считаются миграции, — под блокировкой: up() видит ревизию до неё, и второй upgrade повторил бы миграции, уже
195
+ // накатанные первым. Разные схемы друг друга не ждут: ключ — имя схемы.
196
+ await lockSchema(tx, opts.schema);
197
+ if (opts.fresh)
198
+ await tx.unsafe(`drop schema if exists ${quoteIdent(opts.schema)} cascade`);
199
+ found = await schemaRevision(tx, opts.schema);
200
+ if (opts.migrateFrom !== undefined && found !== null) {
201
+ migrations = await migrationFiles(opts.migrationsDir ?? MIGRATE_DIR, Math.max(opts.migrateFrom, found));
202
+ }
203
+ // Схема создаётся до set role: у владельца может не быть права CREATE в базе.
204
+ await tx.unsafe(`create schema if not exists ${quoteIdent(opts.schema)} authorization ${quoteIdent(owner)}`);
162
205
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
163
206
  // режим сигналов (план 1.1, §2.6): опция, иначе сохранённый в схеме (meta строки ключа), иначе class
164
207
  let saved = null;
@@ -236,7 +279,7 @@ export async function install(sql, opts) {
236
279
  }
237
280
  }
238
281
  });
239
- return { schema: opts.schema, owner, app, serviceKey, migrations, signals };
282
+ return { schema: opts.schema, owner, app, serviceKey, migrations, signals, found };
240
283
  }
241
284
  /** Миграции с номером выше ревизии from и не выше DDL_REVISION: `0004-описание.sql`, по номеру. */
242
285
  export async function migrationFiles(dir, from) {
@@ -313,14 +356,15 @@ export function checkServer(versionNum, encoding) {
313
356
  }
314
357
  /** Ревизия движка схемы по комментарию 'letopis ddl_revision=N'; схемы нет — null. */
315
358
  export async function schemaRevision(sql, schema) {
316
- const [r] = await sql `select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = ${schema}`;
359
+ const [r] = await sql.unsafe(`select obj_description(n.oid, 'pg_namespace') as c from pg_namespace n where n.nspname = $1`, [schema]);
317
360
  if (!r)
318
361
  return null;
319
362
  return Number(/ddl_revision=(\d+)/.exec(String(r.c ?? ''))?.[1] ?? 0);
320
363
  }
321
364
  /**
322
- * Поставить или обновить установку letopis. Схема есть и её ревизия совпадает — повторный накат
323
- * (идемпотентный); ревизия отстала — только с upgrade: true; новее библиотеки — отказ.
365
+ * Ставит или обновляет установку letopis в схеме. Если схема уже есть и её ревизия та же, что у пакета, `up()` выполняет
366
+ * повторный накат: заново ставит функции, не трогая данные, — поэтому его можно вызывать при каждом запуске приложения.
367
+ * Схему отставшей ревизии `up()` обновляет только с `upgrade: true`, а на схеме новее пакета отказывает.
324
368
  */
325
369
  export async function up(opts) {
326
370
  const log = opts.quiet ? () => { } : (m) => console.log(`letopis.up: ${m}`);
@@ -362,11 +406,8 @@ export async function up(opts) {
362
406
  if (!opts.quiet)
363
407
  for (const w of warnings)
364
408
  console.warn(`letopis.up: ${w}`);
365
- if (opts.fresh) {
366
- await sql.unsafe(`drop schema if exists ${quoteIdent(schema)} cascade`);
367
- log(`схема ${schema} удалена (fresh)`);
368
- }
369
- const before = await schemaRevision(sql, schema);
409
+ // ревизия до блокировки схемы — для ранних отказов; что накатывать, install() решает по ревизии под блокировкой
410
+ const before = opts.fresh ? null : await schemaRevision(sql, schema);
370
411
  if (before !== null && before > DDL_REVISION) {
371
412
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, а библиотека знает только ${DDL_REVISION} — обновите пакет`);
372
413
  }
@@ -374,7 +415,11 @@ export async function up(opts) {
374
415
  throw new Error(`letopis.up: схема ${schema} ревизии ${before}, библиотека — ${DDL_REVISION}: обновите её up({ schema: '${schema}', upgrade: true })`);
375
416
  }
376
417
  const { dsn: _d, sql: _s, schema: _sc, fresh: _f, upgrade: _u, quiet: _q, waitTimeoutMs: _w, pglite: _p, ...rest } = opts;
377
- const res = await install(sql, { ...rest, schema, ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}) });
418
+ const { found, ...res } = await install(sql, {
419
+ ...rest, schema, ...(opts.fresh ? { fresh: true } : {}), ...(before !== null && before < DDL_REVISION ? { migrateFrom: before } : {}),
420
+ });
421
+ if (opts.fresh)
422
+ log(`схема ${schema} удалена и поставлена заново (fresh)`);
378
423
  // ANALYZE — только там, где статистики ещё нет: на повторном накате это самая дорогая часть
379
424
  const fresh = await sql `select relname from pg_stat_user_tables
380
425
  where schemaname = ${schema} and last_analyze is null and last_autoanalyze is null order by relname`;
@@ -382,9 +427,9 @@ export async function up(opts) {
382
427
  for (const t of analyzed)
383
428
  await sql.unsafe(`analyze ${quoteIdent(schema)}.${quoteIdent(t)}`);
384
429
  const revision = (await schemaRevision(sql, schema)) ?? 0;
385
- log(`${before === null ? 'схема поставлена' : before < DDL_REVISION ? `схема обновлена с ревизии ${before}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
430
+ log(`${found === null ? 'схема поставлена' : found < DDL_REVISION ? `схема обновлена с ревизии ${found}` : 'повторный накат'}: ${schema}, ревизия ${revision}`);
386
431
  const tenant = opts.tenant ? await createTenant(sql, schema, opts.tenant, opts.owner) : undefined;
387
- return { ...res, created: before === null, upgraded: before !== null && before < DDL_REVISION, revision, analyzed, warnings,
432
+ return { ...res, created: found === null, upgraded: found !== null && found < DDL_REVISION, revision, analyzed, warnings,
388
433
  ...(lite ? { pglite: lite } : {}), ...(tenant ? { tenant } : {}) };
389
434
  }
390
435
  catch (e) {
@@ -399,8 +444,11 @@ export async function up(opts) {
399
444
  await sql.end({ timeout: 5 }).catch(() => { });
400
445
  }
401
446
  }
447
+ /** Служебные виды строк secret: их не принимают функции кредов (auth_kind_ok в 80-auth.sql) и createTenant. */
448
+ const SERVICE_KINDS = ['SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY', 'PROJECTOR', 'LOCK'];
402
449
  /**
403
- * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Членство
450
+ * Опции арендатора до записи; возвращает роли членства пользователя (по умолчанию owner). Вид пароля user.kind — как у
451
+ * функций кредов (auth_kind_ok). Членство
404
452
  * пишет sys_create от владельца — без проверки класса member, поэтому список ролей проверяется здесь:
405
453
  * непустой, без повторов, каждая роль — имя, а не маска группы '{a,b}': не пустое, без пробелов по
406
454
  * краям (маска их отрезает) и запятых (разделитель маски), без фигурных скобок и «!» в начале
@@ -409,6 +457,12 @@ export async function up(opts) {
409
457
  function checkTenant(opts) {
410
458
  if (!opts.name)
411
459
  throw new Error('letopis.createTenant: имя арендатора обязательно');
460
+ // вид пароля — по правилу функций кредов (auth_kind_ok в 80-auth.sql): пароль пишет функция владельца мимо них, а с
461
+ // видом в другом регистре или служебным (SESSION, TOTP…) по нему не войти, и служебные строки он подменял бы
462
+ const kind = opts.user?.kind;
463
+ if (kind != null && (typeof kind !== 'string' || !/^[A-Z][A-Z0-9_]{1,31}$/.test(kind) || SERVICE_KINDS.includes(kind))) {
464
+ throw new LetopisError('invalid_data', `createTenant: user.kind ${JSON.stringify(kind)} — вид пароля: заглавная латинская буква, затем заглавные буквы, цифры или «_», всего от 2 до 32 знаков; служебные виды ${SERVICE_KINDS.join(', ')} заняты (по умолчанию — PASSWORD)`);
465
+ }
412
466
  if (opts.user?.roles === undefined)
413
467
  return ['owner'];
414
468
  const roles = opts.user.roles;
@@ -427,14 +481,10 @@ function checkTenant(opts) {
427
481
  return roles;
428
482
  }
429
483
  /**
430
- * Арендатор и его первый пользователь — то, что раньше делал свой сид функциями владельца
431
- * (sys_create, sys_grant_all). Нужно администраторское подключение (роль может стать владельцем);
432
- * всё — одной транзакцией от владельца. Повтор с тем же именем и логином ничего не дублирует:
433
- * id — v5 от имени и логина, существующие строки пропускаются (пароль и роли членства не меняются),
434
- * сессия выдаётся заново. Удалённые арендатор, аккаунт пользователя или членство (строки нет, а
435
- * журнал id есть) — отказ invalid_data, транзакция откатывается целиком: повтор не воскрешает то,
436
- * что удалил администратор (up({ tenant }) идёт при каждом запуске), а sys_create пишет только
437
- * версию 1 — её занимает история; вернуть строку — restore() цепочкой.
484
+ * Заводит арендатора и, по желанию, его первого пользователя (аккаунт, членство, пароль, сессию) одной транзакцией от
485
+ * роли-владельца схемы — поэтому нужен администраторский пул. Повтор с тем же именем и логином ничего не дублирует и не
486
+ * меняет, только выдаёт пользователю новую сессию; одновременные вызовы заводят арендатора один раз. Если арендатор,
487
+ * пользователь или членство были удалены, повтор их не воскрешает — отказ `invalid_data`.
438
488
  */
439
489
  export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
440
490
  const roles = checkTenant(opts);
@@ -444,6 +494,9 @@ export async function createTenant(sql, schema, opts, owner = 'letopis_owner') {
444
494
  const user = u ? v5Id(SYSTEM_ID, 'Account', [`user:${u.login}`]) : null;
445
495
  const hash = u ? await hashPassword(u.password) : null;
446
496
  return sql.begin(async (tx) => {
497
+ // под блокировкой схемы, как накат: up({ tenant }) нескольких экземпляров сервиса разом заводит арендатора один
498
+ // раз, остальные находят его по имени (без неё оба вставили бы ту же строку и один упал бы на ключе)
499
+ await lockSchema(tx, schema);
447
500
  await tx.unsafe(`set local role ${quoteIdent(owner)}`);
448
501
  /** Строка есть — true, не было — false; строки нет, а журнал id есть, — удалена: отказ gone (что и как вернуть). */
449
502
  const exists = async (id, cls, gone) => {
@@ -17,8 +17,15 @@ export interface Validator {
17
17
  /** Нарушения ([] — данные приняты). */
18
18
  (data: unknown): Issue[];
19
19
  }
20
- /** Компилирует JSON Schema (разворот resolved.schema класса) в функцию проверки. */
20
+ /**
21
+ * Превращает JSON Schema данных класса в функцию проверки — ту же проверку, что выполняет база при записи. Схема
22
+ * класса — `db.registry.resolve(имя).schema`.
23
+ */
21
24
  export declare function compileValidator(schema: JsonSchema): Validator;
22
- /** Умолчания, как apply_defaults в базе: на каждом уровне, во вложенные объекты — только если они есть. */
25
+ /**
26
+ * Подставляет в данные значения по умолчанию (`default`) из JSON Schema класса, как база перед проверкой при каждой
27
+ * записи: только в отсутствующие поля, а во вложенный объект — только если он в данных уже есть. Исходные данные не
28
+ * меняются.
29
+ */
23
30
  export declare function applyDefaults(schema: JsonSchema, data: unknown): unknown;
24
31
  export {};