letopis 1.4.0 → 1.4.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 (52) hide show
  1. package/AGENT-CHEATSHEET.en.md +10 -5
  2. package/AGENT-CHEATSHEET.md +14 -9
  3. package/CHANGELOG.md +159 -0
  4. package/README.en.md +119 -52
  5. package/README.md +107 -47
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +42 -16
  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/chain.d.ts +91 -15
  13. package/dist/chain.js +47 -4
  14. package/dist/cli.js +6 -0
  15. package/dist/errors.d.ts +2 -2
  16. package/dist/errors.js +5 -5
  17. package/dist/import.js +7 -0
  18. package/dist/index.d.ts +74 -45
  19. package/dist/index.js +12 -5
  20. package/dist/load.d.ts +10 -3
  21. package/dist/load.js +19 -4
  22. package/dist/mcp.d.ts +20 -11
  23. package/dist/mcp.js +4 -4
  24. package/dist/model.d.ts +4 -1
  25. package/dist/ops.d.ts +58 -18
  26. package/dist/ops.js +58 -18
  27. package/dist/project.d.ts +30 -11
  28. package/dist/project.js +30 -4
  29. package/dist/registry.d.ts +4 -1
  30. package/dist/registry.js +4 -1
  31. package/dist/sql.d.ts +4 -1
  32. package/dist/sql.js +12 -4
  33. package/dist/tx.js +10 -1
  34. package/dist/types.d.ts +14 -7
  35. package/dist/types.js +13 -6
  36. package/dist/up.d.ts +48 -25
  37. package/dist/up.js +89 -41
  38. package/dist/validate.d.ts +9 -2
  39. package/dist/validate.js +19 -3
  40. package/dist/watch.d.ts +17 -5
  41. package/dist/watch.js +40 -8
  42. package/dist/write.d.ts +3 -2
  43. package/dist/write.js +29 -10
  44. package/package.json +1 -1
  45. package/sql/10-core.sql +6 -1
  46. package/sql/60-write.sql +38 -18
  47. package/sql/80-auth.sql +39 -2
  48. package/sql/88-derived.sql +1 -0
  49. package/sql/90-time.sql +15 -5
  50. package/sql/95-seed.system.sql +6 -3
  51. package/sql/99-revision.sql +1 -1
  52. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/ops.js CHANGED
@@ -6,39 +6,79 @@
6
6
  import { OP } from './types.js';
7
7
  const op = (name, ...args) => ({ [OP]: name, args });
8
8
  export const isOp = (v) => typeof v === 'object' && v !== null && OP in v;
9
- /** ≠ (IS DISTINCT FROM) */
9
+ /**
10
+ * Не равно: `db.Order({ status: ne('cancelled') })`. Подходят и строки, где поля нет или в нём `null`, а `ne(null)` —
11
+ * строки, где у поля есть значение.
12
+ */
10
13
  export const ne = (v) => op('ne', v);
11
- /** > */
14
+ /**
15
+ * Больше: `db.Product({ price: gt(1000) })`. Сравнение — по типу поля из описания класса (числа — как числа, даты — как
16
+ * даты), поле без описания сравнивается как текст; строки без поля не подходят.
17
+ */
12
18
  export const gt = (v) => op('gt', v);
13
- /** ≥ */
19
+ /** Больше или равно: `db.Product({ price: gte(500) })`. Сравнение — как у `gt()`. */
14
20
  export const gte = (v) => op('gte', v);
15
- /** < */
21
+ /** Меньше: `db.Product({ price: lt(1000) })`. Сравнение — как у `gt()`. */
16
22
  export const lt = (v) => op('lt', v);
17
- /** ≤ */
23
+ /** Меньше или равно: `db.Product({ price: lte(900) })`. Сравнение — как у `gt()`. */
18
24
  export const lte = (v) => op('lte', v);
19
- /** a ≤ x ≤ b */
25
+ /**
26
+ * От `a` до `b` включительно: `db.Product({ price: between(500, 3000) })`. Сравнение — как у `gt()`; если `a` больше
27
+ * `b`, не подходит ни одна строка.
28
+ */
20
29
  export const between = (a, b) => op('between', a, b);
21
- /** значение из списка */
30
+ /**
31
+ * Одно из значений списка: `db.Product({ sku: inList(['K-1', 'M-1']) })`. Сравнение — по типу поля, как у `gt()`;
32
+ * пустой список не находит ничего, а `null` в списке не находит строк без значения — для них есть `isNull()`.
33
+ */
22
34
  export const inList = (vs) => op('in', vs);
23
- /** LIKE (шаблон с % и _) */
35
+ /**
36
+ * Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
37
+ * `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
38
+ * `like('100\\%')`.
39
+ */
24
40
  export const like = (s) => op('like', s);
25
- /** ILIKE (без регистра) */
41
+ /** Как `like()`, но без учёта регистра: `db.Product({ title: ilike('%чай%') })`. */
26
42
  export const ilike = (s) => op('ilike', s);
27
- /** начинается с */
43
+ /**
44
+ * Текст начинается с `s`, с учётом регистра: `db.Product({ sku: starts('T-') })`. Это `like(s + '%')`: `%`, `_` и
45
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
46
+ */
28
47
  export const starts = (s) => op('like', s + '%');
29
- /** заканчивается на */
48
+ /**
49
+ * Текст заканчивается на `s`, с учётом регистра: `db.Product({ sku: ends('-1') })`. Это `like('%' + s)`: `%`, `_` и
50
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
51
+ */
30
52
  export const ends = (s) => op('like', '%' + s);
31
- /** массив-поле содержит значение */
53
+ /**
54
+ * Поле-массив содержит элемент `v`: `db.Product({ labels: has('хит') })`. Тип важен: `has(5)` найдёт число 5, но не
55
+ * строку `'5'`.
56
+ */
32
57
  export const has = (v) => op('has', v);
33
- /** массив-поле содержит хотя бы одно из */
58
+ /**
59
+ * Поле-массив содержит хотя бы один элемент из списка: `db.Product({ labels: hasAny(['хит', 'акция']) })`. Находит
60
+ * только строковые элементы; пустой список не находит ничего.
61
+ */
34
62
  export const hasAny = (vs) => op('hasAny', vs);
35
- /** массив-поле содержит все */
63
+ /**
64
+ * Поле-массив содержит все элементы списка, в любом порядке: `db.Product({ labels: hasAll(['хит', 'стекло']) })`. Тип
65
+ * элементов важен, как у `has()`; пустой список подходит любой строке, даже без этого поля.
66
+ */
36
67
  export const hasAll = (vs) => op('hasAll', vs);
37
- /** поле присутствует (true) или отсутствует (false) */
68
+ /**
69
+ * Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть:
70
+ * `db.Product({ labels: exists(false) })` — товары без поля `labels`.
71
+ */
38
72
  export const exists = (yes = true) => op('exists', yes);
39
- /** поле null или отсутствует */
73
+ /** В поле `null` или поля нет совсем: `db.Product({ specs: isNull() })`. Отличить одно от другого помогает `exists()`. */
40
74
  export const isNull = () => op('isNull');
41
- /** НЕ-условие по полю: not(ilike('%тест%')); скаляр — «не равно» */
75
+ /**
76
+ * Отрицание оператора: `db.Product({ price: not(gte(1000)) })` — цена меньше 1000 или поля нет. Простое значение вместо
77
+ * оператора значит «не равно», как `ne()`: `db.Order({ status: not('cancelled') })`.
78
+ */
42
79
  export const not = (v) => op('not', v);
43
- /** ИЛИ на уровне фильтра шага: t.booking(or({ notes: 'a' }, { notes: 'b' })) */
80
+ /**
81
+ * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
82
+ * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и».
83
+ */
44
84
  export const or = (...filters) => op('or', filters);
package/dist/project.d.ts CHANGED
@@ -61,18 +61,25 @@ export interface ProjectorOut {
61
61
  tenant: string;
62
62
  }): void;
63
63
  /**
64
- * Создать (поля без строки — от 0) или дополнить строку и прибавить числа: коммутативная свёртка без чтения.
65
- * Приращения строки, заданной одними полями ключа (или одним $id), складываются до отправки в базу, пока между ними
66
- * нет upsert, delete или get: одна правка строки на пачку.
64
+ * Прибавляет к полям строки числа из `deltas`: читать строку не нужно, и итог не зависит от порядка приращений. Поле
65
+ * приращения, которого в строке нет, считается от 0, а если нет самой строки, она создаётся; поля `row` дописываются в
66
+ * её данные. Приращения одной строки, заданной только полями ключа (или только `$id`), до отправки в базу складываются
67
+ * в одну правку, пока между ними нет `upsert`, `delete` или `get`.
67
68
  */
68
69
  inc(cls: string, row: ProjectorRow, deltas: Record<string, number>, opts: {
69
70
  tenant: string;
70
71
  }): void;
71
- /** Удалить по ключу или id; строки нет — ничего. */
72
+ /**
73
+ * Удалить строку: у класса с ключом — по полям ключа, у класса без ключа — по id (строкой или { $id }); строки нет —
74
+ * ничего. Один id у класса с ключом — invalid_data.
75
+ */
72
76
  delete(cls: string, keyOrId: string | ProjectorRow, opts: {
73
77
  tenant: string;
74
78
  }): void;
75
- /** Текущая строка внутри пачки (с записями пачки, сделанными до вызова); нет — null. */
79
+ /**
80
+ * Текущая строка внутри пачки (с записями пачки, сделанными до вызова); нет — null. Строку называют, как в delete: у
81
+ * класса с ключом — полями ключа, у класса без ключа — id; один id у класса с ключом — invalid_data.
82
+ */
76
83
  get(cls: string, keyOrId: string | ProjectorRow, opts: {
77
84
  tenant: string;
78
85
  }): Promise<Row | null>;
@@ -82,21 +89,28 @@ export interface ProjectorOptions {
82
89
  from: string[];
83
90
  /** Производные классы (с потомками): описание каждого — derivedBy: имя проектора. */
84
91
  into: string[];
85
- /** Версия проектора: другая версия в чекпоинте — rebuild_required, нужна перестройка. */
92
+ /**
93
+ * Версия логики проектора — целое число от 1. Вместе с позицией в журнале проектор хранит в базе версию, которой
94
+ * построена проекция: если она другая, `run()` и `rebuild({ tenant })` отклоняются с `rebuild_required`, а полная
95
+ * перестройка `rebuild()` строит проекцию заново и запоминает новую версию.
96
+ */
86
97
  version: number;
87
- /** Изменения одного арендатора (пачка делится по арендаторам) и запись его результатов. */
98
+ /**
99
+ * Функция `handle(changes, out)`: получает изменения одного арендатора и пишет его результаты через `out`. Пачку журнала
100
+ * проектор делит по арендаторам и вызывает `handle` для каждого по очереди.
101
+ */
88
102
  handle(changes: ProjectorChange[], out: ProjectorOut): void | Promise<void>;
89
103
  /** Подавать прошлую версию объекта (before). */
90
104
  withBefore?: boolean;
91
105
  /**
92
106
  * Курсор старше яруса all политики хранения источников (cursor_expired): 'rebuild' — перестроить самому, пачками.
93
- * Ярус — по арендатору: истёк у части участников — выбывают они (с 'rebuild' — перестраиваются сами, каждый своей
94
- * транзакцией), у всех — вся проекция.
107
+ * Ярус — по арендатору: истёк у части участников — выбывают они (с 'rebuild' — перестраиваются сами, по одному, пачками), у
108
+ * всех — вся проекция.
95
109
  */
96
110
  onExpired?: 'rebuild' | 'error';
97
111
  /** Изменений в пачке (по умолчанию 500). */
98
112
  batch?: number;
99
- /** Запасной опрос журнала, мс (по умолчанию 1000; сигналы источников будят раньше). */
113
+ /** Как часто проектор сам проверяет журнал, если сигнал не пришёл, мс (по умолчанию 1000); сигналы источников будят его раньше. */
100
114
  pollMs?: number;
101
115
  /**
102
116
  * Сколько транзакция проектора ждёт чужую блокировку строки, мс (по умолчанию 5000; 0 — без предела). Дольше —
@@ -280,8 +294,13 @@ export declare class Projector {
280
294
  private ours;
281
295
  /** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
282
296
  private leaves;
283
- /** Класс результата в арендаторе — из его семейства into. */
297
+ /**
298
+ * Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
299
+ * метод: им `out` проверяет, что `handle` пишет только в эти классы.
300
+ */
284
301
  intoOf(tenant: string, cls: string): boolean;
302
+ /** Поля ключа класса into арендатора (пусто — класс без ключа). */
303
+ keyOf(tenant: string, cls: string): string[];
285
304
  /**
286
305
  * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
287
306
  * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
package/dist/project.js CHANGED
@@ -67,6 +67,10 @@ function change(row, cursor, rebuild = false) {
67
67
  * значениями (22…) и пределами размера (54…). Сбой окружения (блокировки, отмена запроса, связь) — не причина
68
68
  * выводить арендатора: пачка откатывается целиком, повтор начнётся с того же курсора.
69
69
  */
70
+ /** «1 прохода», «2 проходов», «21 прохода» — число проходов в родительном падеже («в течение …»). */
71
+ function passesGen(n) {
72
+ return `${n} ${n % 10 === 1 && n % 100 !== 11 ? 'прохода' : 'проходов'}`;
73
+ }
70
74
  /** Ожидание чужой блокировки строки дольше lock_timeout (55P03): сбой окружения, проход повторяется. */
71
75
  function lockWait(e) {
72
76
  return e?.code === '55P03';
@@ -161,17 +165,32 @@ class Out {
161
165
  if (key !== null)
162
166
  this.folds.set(key, d);
163
167
  }
168
+ /**
169
+ * Строка для delete и get. У класса с ключом её называют полями ключа: id такой строки — v5 от ключа, и по одному id
170
+ * (строкой или { $id }) база искала бы строку с пустым ключом — get отвечал бы null, а delete выводил арендатора с
171
+ * id_mismatch. Это ошибка кода handle, а не данных: invalid_data сразу, пачка откатывается целиком.
172
+ */
173
+ target(cls, tenant, keyOrId) {
174
+ const row = this.rowOf(keyOrId);
175
+ const key = this.p.keyOf(tenant, cls);
176
+ if (key.length && Object.keys(row).every((n) => n === '$id')) {
177
+ throw new LetopisError('invalid_data', `проектор ${this.p.name}: у класса ${cls} есть ключ (${key.join(', ')}) — строку называют полями ключа, например { ${key.map((k) => `${k}: …`).join(', ')} }, а не id`);
178
+ }
179
+ return row;
180
+ }
164
181
  delete(cls, keyOrId, opts) {
165
182
  const tenant = this.check(cls, opts?.tenant);
183
+ const row = this.target(cls, tenant, keyOrId);
166
184
  this.folds.clear();
167
- this.ops.push({ op: 'delete', class: cls, tenant, row: this.rowOf(keyOrId) });
185
+ this.ops.push({ op: 'delete', class: cls, tenant, row });
168
186
  }
169
187
  async get(cls, keyOrId, opts) {
170
188
  const tenant = this.check(cls, opts?.tenant);
189
+ const row = this.target(cls, tenant, keyOrId);
171
190
  await this.flush();
172
191
  const S = this.p.env.S;
173
192
  const [r] = await this.db(() => this.q.unsafe(`select ${rowJson('e')} as row from ${S}.entity e
174
- where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(this.rowOf(keyOrId))]));
193
+ where e.id = ${S}.derived_id($1, $2::uuid, $3::text::jsonb) and e.class = $1 and e.tenant = $2::uuid`, [cls, tenant, JSON.stringify(row)]));
175
194
  return r ? toRow(r.row) : null;
176
195
  }
177
196
  async flush() {
@@ -751,7 +770,7 @@ export class Projector {
751
770
  failed[tenant] = {
752
771
  cursor: list[0].cursor || start,
753
772
  code: '55P03',
754
- message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — ${hits} проходов подряд дольше lockTimeout`,
773
+ message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — в течение ${passesGen(hits)} подряд дольше lockTimeout`,
755
774
  };
756
775
  continue;
757
776
  }
@@ -808,10 +827,17 @@ export class Projector {
808
827
  const sc = this.scopes.get(row.tenant);
809
828
  return sc !== undefined && !failed?.[row.tenant] && row.op === 'reclass' && !row.moved && !sc.from.has(row.class) && row.class !== '#reset';
810
829
  }
811
- /** Класс результата в арендаторе — из его семейства into. */
830
+ /**
831
+ * Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
832
+ * метод: им `out` проверяет, что `handle` пишет только в эти классы.
833
+ */
812
834
  intoOf(tenant, cls) {
813
835
  return this.scopes.get(tenant)?.into.has(cls) ?? false;
814
836
  }
837
+ /** Поля ключа класса into арендатора (пусто — класс без ключа). */
838
+ keyOf(tenant, cls) {
839
+ return this.scopes.get(tenant)?.keys.get(cls) ?? [];
840
+ }
815
841
  /**
816
842
  * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
817
843
  * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
@@ -68,7 +68,10 @@ export declare class Registry {
68
68
  all(): ClassInfo[];
69
69
  /** Класс и все его потомки (семейство). */
70
70
  family(name: string): ClassInfo[];
71
- /** Тип листа по пути полей класса: по JSON Schema (type и format). */
71
+ /**
72
+ * Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
73
+ * библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
74
+ */
72
75
  leafType(cls: string, path: readonly string[]): LeafType;
73
76
  }
74
77
  export {};
package/dist/registry.js CHANGED
@@ -70,7 +70,10 @@ export class Registry {
70
70
  const root = this.resolve(name).name;
71
71
  return this.all().filter((c) => c.ancestors.includes(root));
72
72
  }
73
- /** Тип листа по пути полей класса: по JSON Schema (type и format). */
73
+ /**
74
+ * Тип листа — поля в конце пути `path` — у класса `cls`, по его JSON Schema (`type` и `format`). По этому типу
75
+ * библиотека сравнивает значения поля в фильтрах, сортировке и итогах.
76
+ */
74
77
  leafType(cls, path) {
75
78
  let node = this.resolve(cls).schema;
76
79
  for (const p of path) {
package/dist/sql.d.ts CHANGED
@@ -62,7 +62,10 @@ export interface Hop {
62
62
  * хаб → связь — назад; связь → хаб и хаб → хаб — вперёд; нет нужного — другое направление.
63
63
  */
64
64
  export declare function resolveHop(reg: Registry, prev: Step, next: Step): Hop;
65
- /** Курсор keyset-пагинации из последней строки страницы. */
65
+ /**
66
+ * Курсор постраничного вывода для `after()`: `{ v, id }` — значение поля сортировки и id последней строки страницы.
67
+ * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
68
+ */
66
69
  export declare function cursorOf(row: Row, field: string): Cursor;
67
70
  export interface BuildOptions {
68
71
  /**
package/dist/sql.js CHANGED
@@ -422,7 +422,10 @@ function cursorParams(c, sort) {
422
422
  }
423
423
  return { v: value, id };
424
424
  }
425
- /** Курсор keyset-пагинации из последней строки страницы. */
425
+ /**
426
+ * Курсор постраничного вывода для `after()`: `{ v, id }` — значение поля сортировки и id последней строки страницы.
427
+ * Поле — то же, что в `sort()`; если его нет в данных строки, `v` — `null`.
428
+ */
426
429
  export function cursorOf(row, field) {
427
430
  const f = field === 'updated' ? 'at' : field;
428
431
  if (f === 'at' || f === 'rev' || f === 'id')
@@ -609,10 +612,13 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
609
612
  + `where (v.op = 'delete') = ${isDead} and v.op not in ('purge', 'trim', 'reset')${dataCond('v')}${endsCond(`${S}.link_ids(v.links)`)}${rolesCond('v', 's.src')})`;
610
613
  };
611
614
  ctes.push(`p${i}a as (select q.src, q.id, q.rev from ${hopCall(atPh, false)} q)`);
612
- parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select q.id, q.rev from p${i}a q`));
615
+ // пары переходов — по одной на (источник, цель): объект, который ссылается на несколько объектов прошлого шага,
616
+ // встречается в них несколько раз; строка узла — одна на id, иначе rows(), count() и агрегаты его удваивали
617
+ // (entityByIds повторы отбрасывает сам, срез asOf() и надгробия withDeleted() — через distinct здесь)
618
+ parts.push(cur ? entityByIds(`select q.id from p${i}a q`) : logPart(`select distinct q.id, q.rev from p${i}a q`));
613
619
  if (dead) {
614
620
  ctes.push(`p${i}d as (select q.src, q.id, q.rev from ${hopCall(atPh, true)} q)`);
615
- parts.push(logPart(`select q.id, q.rev from p${i}d q`));
621
+ parts.push(logPart(`select distinct q.id, q.rev from p${i}d q`));
616
622
  ctes.push(`p${i} as (select src, id from p${i}a union all select src, id from p${i}d)`);
617
623
  }
618
624
  else {
@@ -728,7 +734,9 @@ export function buildRead(reg, schema, steps, mods, mode, agg, opts = {}) {
728
734
  }
729
735
  else {
730
736
  const cast = agg.fn === 'sum' || agg.fn === 'avg' ? '::numeric' : CAST[aggLeaf];
731
- text = `${withClause}select ${agg.fn}((${acc})${cast}) as v from ${ents} z`;
737
+ // у boolean в PostgreSQL нет min и max; false меньше true: наименьшее — «все ли true», наибольшее — «есть ли true»
738
+ const fn = aggLeaf === 'boolean' && agg.fn === 'min' ? 'bool_and' : aggLeaf === 'boolean' && agg.fn === 'max' ? 'bool_or' : agg.fn;
739
+ text = `${withClause}select ${fn}((${acc})${cast}) as v from ${ents} z`;
732
740
  }
733
741
  break;
734
742
  }
package/dist/tx.js CHANGED
@@ -282,7 +282,16 @@ export class ExplicitTx {
282
282
  let changed = null;
283
283
  try {
284
284
  // чтение 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')]);
285
+ const read = this.conn.unsafe(`select current_setting('letopis.changed', true) as changed`);
286
+ const commit = this.conn.unsafe('commit');
287
+ const [rows, res] = (await Promise.all([read, commit]).catch(async (e) => {
288
+ // транзакцию уже прервала ошибка, которую код внутри db.begin() поймал и проглотил: чтение получает 25P02,
289
+ // а COMMIT того же конвейера отвечает ROLLBACK — это tx_rolled_back, а не сырая ошибка PostgreSQL
290
+ const tail = e.code === '25P02' ? await commit.catch(() => null) : null;
291
+ if (tail?.command === 'ROLLBACK')
292
+ return [[], tail];
293
+ throw e;
294
+ }));
286
295
  changed = rows[0]?.changed ?? null;
287
296
  if (res.command === 'ROLLBACK') {
288
297
  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,7 +85,7 @@ export type Filter = string | string[] | Op | {
78
85
  } | {
79
86
  [field: string]: FieldFilter | undefined;
80
87
  };
81
- /** Курсор keyset-пагинации: значение поля сортировки и id последней строки страницы. */
88
+ /** Курсор постраничного вывода для `after()`: значение поля сортировки и id последней строки страницы. */
82
89
  export interface Cursor {
83
90
  v: string | number | boolean | null;
84
91
  id: string;
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 {};