letopis 1.3.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 (56) hide show
  1. package/AGENT-CHEATSHEET.en.md +14 -6
  2. package/AGENT-CHEATSHEET.md +18 -10
  3. package/CHANGELOG.md +247 -0
  4. package/README.en.md +146 -58
  5. package/README.md +129 -51
  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 +18 -4
  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 +44 -12
  28. package/dist/project.js +90 -8
  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/sync.js +11 -5
  34. package/dist/tx.js +10 -1
  35. package/dist/types.d.ts +14 -7
  36. package/dist/types.js +13 -6
  37. package/dist/up.d.ts +65 -25
  38. package/dist/up.js +152 -40
  39. package/dist/validate.d.ts +9 -2
  40. package/dist/validate.js +19 -3
  41. package/dist/watch.d.ts +17 -5
  42. package/dist/watch.js +40 -8
  43. package/dist/write.d.ts +3 -2
  44. package/dist/write.js +29 -10
  45. package/llms.txt +1 -1
  46. package/package.json +1 -1
  47. package/sql/10-core.sql +6 -1
  48. package/sql/50-acl.sql +5 -1
  49. package/sql/60-write.sql +77 -22
  50. package/sql/80-auth.sql +39 -2
  51. package/sql/88-derived.sql +82 -1
  52. package/sql/90-time.sql +75 -38
  53. package/sql/95-seed.system.sql +6 -3
  54. package/sql/99-revision.sql +3 -2
  55. package/sql/README.md +2 -0
  56. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/ops.d.ts CHANGED
@@ -5,39 +5,79 @@
5
5
  */
6
6
  import { type Op, type Scalar } from './types.js';
7
7
  export declare const isOp: (v: unknown) => v is Op;
8
- /** ≠ (IS DISTINCT FROM) */
8
+ /**
9
+ * Не равно: `db.Order({ status: ne('cancelled') })`. Подходят и строки, где поля нет или в нём `null`, а `ne(null)` —
10
+ * строки, где у поля есть значение.
11
+ */
9
12
  export declare const ne: (v: Scalar) => Op;
10
- /** > */
13
+ /**
14
+ * Больше: `db.Product({ price: gt(1000) })`. Сравнение — по типу поля из описания класса (числа — как числа, даты — как
15
+ * даты), поле без описания сравнивается как текст; строки без поля не подходят.
16
+ */
11
17
  export declare const gt: (v: Scalar) => Op;
12
- /** ≥ */
18
+ /** Больше или равно: `db.Product({ price: gte(500) })`. Сравнение — как у `gt()`. */
13
19
  export declare const gte: (v: Scalar) => Op;
14
- /** < */
20
+ /** Меньше: `db.Product({ price: lt(1000) })`. Сравнение — как у `gt()`. */
15
21
  export declare const lt: (v: Scalar) => Op;
16
- /** ≤ */
22
+ /** Меньше или равно: `db.Product({ price: lte(900) })`. Сравнение — как у `gt()`. */
17
23
  export declare const lte: (v: Scalar) => Op;
18
- /** a ≤ x ≤ b */
24
+ /**
25
+ * От `a` до `b` включительно: `db.Product({ price: between(500, 3000) })`. Сравнение — как у `gt()`; если `a` больше
26
+ * `b`, не подходит ни одна строка.
27
+ */
19
28
  export declare const between: (a: Scalar, b: Scalar) => Op;
20
- /** значение из списка */
29
+ /**
30
+ * Одно из значений списка: `db.Product({ sku: inList(['K-1', 'M-1']) })`. Сравнение — по типу поля, как у `gt()`;
31
+ * пустой список не находит ничего, а `null` в списке не находит строк без значения — для них есть `isNull()`.
32
+ */
21
33
  export declare const inList: (vs: Scalar[]) => Op;
22
- /** LIKE (шаблон с % и _) */
34
+ /**
35
+ * Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
36
+ * `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
37
+ * `like('100\\%')`.
38
+ */
23
39
  export declare const like: (s: string) => Op;
24
- /** ILIKE (без регистра) */
40
+ /** Как `like()`, но без учёта регистра: `db.Product({ title: ilike('%чай%') })`. */
25
41
  export declare const ilike: (s: string) => Op;
26
- /** начинается с */
42
+ /**
43
+ * Текст начинается с `s`, с учётом регистра: `db.Product({ sku: starts('T-') })`. Это `like(s + '%')`: `%`, `_` и
44
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
45
+ */
27
46
  export declare const starts: (s: string) => Op;
28
- /** заканчивается на */
47
+ /**
48
+ * Текст заканчивается на `s`, с учётом регистра: `db.Product({ sku: ends('-1') })`. Это `like('%' + s)`: `%`, `_` и
49
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
50
+ */
29
51
  export declare const ends: (s: string) => Op;
30
- /** массив-поле содержит значение */
52
+ /**
53
+ * Поле-массив содержит элемент `v`: `db.Product({ labels: has('хит') })`. Тип важен: `has(5)` найдёт число 5, но не
54
+ * строку `'5'`.
55
+ */
31
56
  export declare const has: (v: Scalar) => Op;
32
- /** массив-поле содержит хотя бы одно из */
57
+ /**
58
+ * Поле-массив содержит хотя бы один элемент из списка: `db.Product({ labels: hasAny(['хит', 'акция']) })`. Находит
59
+ * только строковые элементы; пустой список не находит ничего.
60
+ */
33
61
  export declare const hasAny: (vs: Scalar[]) => Op;
34
- /** массив-поле содержит все */
62
+ /**
63
+ * Поле-массив содержит все элементы списка, в любом порядке: `db.Product({ labels: hasAll(['хит', 'стекло']) })`. Тип
64
+ * элементов важен, как у `has()`; пустой список подходит любой строке, даже без этого поля.
65
+ */
35
66
  export declare const hasAll: (vs: Scalar[]) => Op;
36
- /** поле присутствует (true) или отсутствует (false) */
67
+ /**
68
+ * Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть:
69
+ * `db.Product({ labels: exists(false) })` — товары без поля `labels`.
70
+ */
37
71
  export declare const exists: (yes?: boolean) => Op;
38
- /** поле null или отсутствует */
72
+ /** В поле `null` или поля нет совсем: `db.Product({ specs: isNull() })`. Отличить одно от другого помогает `exists()`. */
39
73
  export declare const isNull: () => Op;
40
- /** НЕ-условие по полю: not(ilike('%тест%')); скаляр — «не равно» */
74
+ /**
75
+ * Отрицание оператора: `db.Product({ price: not(gte(1000)) })` — цена меньше 1000 или поля нет. Простое значение вместо
76
+ * оператора значит «не равно», как `ne()`: `db.Order({ status: not('cancelled') })`.
77
+ */
41
78
  export declare const not: (v: Scalar | Op) => Op;
42
- /** ИЛИ на уровне фильтра шага: t.booking(or({ notes: 'a' }, { notes: 'b' })) */
79
+ /**
80
+ * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
81
+ * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и».
82
+ */
43
83
  export declare const or: (...filters: Record<string, unknown>[]) => Op;
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,27 +89,40 @@ 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 — без предела). Дольше —
103
117
  * пачка откатывается, курсор остаётся, run() не отклоняется, а пропускает проход и повторяет его.
104
118
  */
105
119
  lockTimeout?: number;
120
+ /**
121
+ * Сколько проходов подряд запись одного арендатора может упираться в lockTimeout, прежде чем он выбудет (код 55P03,
122
+ * дальше — rebuild({ tenant })), а пачка пойдёт для остальных; по умолчанию 0 — не выбывает, проход повторяется для всех.
123
+ * Счёт — в процессе, удачная запись арендатора его сбрасывает.
124
+ */
125
+ lockRetries?: number;
106
126
  /**
107
127
  * Явный список арендаторов проектора: участники — только они (и только те, у кого каждый класс into помечен
108
128
  * derivedBy). Без списка участник — любой арендатор с такими классами.
@@ -118,10 +138,12 @@ export interface ProjectorReport {
118
138
  updated: number;
119
139
  deleted: number;
120
140
  unchanged: number;
121
- /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе. */
141
+ /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе или строку держит чужая транзакция. */
122
142
  skipped: number;
123
143
  /** Перестроек. */
124
144
  rebuilds: number;
145
+ /** Арендаторы, чья запись упиралась в чужую блокировку строки дольше lockTimeout (по порядку, без повторов). */
146
+ locked: string[];
125
147
  }
126
148
  /** Выбывший арендатор: база отклонила запись его результатов. */
127
149
  export interface ProjectorFailure {
@@ -182,6 +204,9 @@ export declare class Projector {
182
204
  private readonly batch;
183
205
  private readonly pollMs;
184
206
  private readonly lockMs;
207
+ private readonly lockRetries;
208
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
209
+ private readonly lockHits;
185
210
  private readonly only;
186
211
  constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
187
212
  /**
@@ -269,8 +294,13 @@ export declare class Projector {
269
294
  private ours;
270
295
  /** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
271
296
  private leaves;
272
- /** Класс результата в арендаторе — из его семейства into. */
297
+ /**
298
+ * Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
299
+ * метод: им `out` проверяет, что `handle` пишет только в эти классы.
300
+ */
273
301
  intoOf(tenant: string, cls: string): boolean;
302
+ /** Поля ключа класса into арендатора (пусто — класс без ключа). */
303
+ keyOf(tenant: string, cls: string): string[];
274
304
  /**
275
305
  * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
276
306
  * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
@@ -312,6 +342,8 @@ export declare class Projector {
312
342
  * true }). true — работа ещё осталась.
313
343
  */
314
344
  private tenantWork;
345
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
346
+ private untilFree;
315
347
  /** Ждать сигнал источников, остановку или запасной опрос. */
316
348
  private sleep;
317
349
  }
package/dist/project.js CHANGED
@@ -50,7 +50,7 @@ function withFailed(meta, failed) {
50
50
  delete next.failed;
51
51
  return next;
52
52
  }
53
- const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0 });
53
+ const emptyReport = () => ({ batches: 0, changes: 0, created: 0, updated: 0, deleted: 0, unchanged: 0, skipped: 0, rebuilds: 0, locked: [] });
54
54
  function change(row, cursor, rebuild = false) {
55
55
  return {
56
56
  cursor, id: row.id, class: row.class, tenant: row.tenant, op: rebuild ? 'create' : row.op, rev: row.rev, at: row.at,
@@ -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() {
@@ -211,6 +230,9 @@ export class Projector {
211
230
  batch;
212
231
  pollMs;
213
232
  lockMs;
233
+ lockRetries;
234
+ /** Проходов подряд, в которых запись арендатора упиралась в lockTimeout (lockRetries). */
235
+ lockHits = new Map();
214
236
  only;
215
237
  constructor(env, name, def) {
216
238
  this.env = env;
@@ -238,6 +260,9 @@ export class Projector {
238
260
  this.lockMs = def.lockTimeout ?? 5000;
239
261
  if (!Number.isInteger(this.lockMs) || this.lockMs < 0)
240
262
  throw new LetopisError('invalid_data', `проектор ${name}: lockTimeout — целое число мс, 0 и больше`);
263
+ this.lockRetries = def.lockRetries ?? 0;
264
+ if (!Number.isInteger(this.lockRetries) || this.lockRetries < 0)
265
+ throw new LetopisError('invalid_data', `проектор ${name}: lockRetries — целое число, 0 и больше`);
241
266
  if (!Number.isInteger(this.batch) || this.batch < 1 || this.batch > 10_000)
242
267
  throw new LetopisError('invalid_data', `проектор ${name}: batch — от 1 до 10 000`);
243
268
  if (env.onSignal) {
@@ -338,14 +363,19 @@ export class Projector {
338
363
  const started = rep.rebuilds;
339
364
  for (;;) {
340
365
  // занятая строка (lockTimeout) — как занятая блокировка проектора: подождать и повторить пачку
341
- const r = await this.tenantChunk(rep, tenant).catch((e) => { if (lockWait(e))
342
- return 'busy'; throw e; });
366
+ const r = await this.tenantChunk(rep, tenant).catch((e) => {
367
+ if (!lockWait(e))
368
+ throw e;
369
+ rep.skipped++;
370
+ return 'busy';
371
+ });
343
372
  if (r === 'busy')
344
373
  await new Promise((res) => setTimeout(res, 20));
345
374
  if (r !== 'done')
346
375
  continue;
347
- // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший
348
- const f = (await this.tx(async (q) => this.state(q))).failed?.[tenant];
376
+ // ошибка данных арендатора — как у перестройки одной транзакцией: отказ, арендатор выбывший; чекпоинт держит
377
+ // пачка run() другого процесса дольше lockTimeout — подождать
378
+ const f = (await this.untilFree(rep, () => this.tx(async (q) => this.state(q)))).failed?.[tenant];
349
379
  if (f)
350
380
  throw new LetopisError(f.code, f.message);
351
381
  // последнюю пачку мог записать run() другого процесса (он продвигает начатые перестройки): перестройка этого
@@ -723,6 +753,27 @@ export class Projector {
723
753
  catch (e) {
724
754
  // причина — первая ошибка базы группы (следом за ней база отвечает лишь «транзакция прервана»)
725
755
  const cause = out.aborted ?? e;
756
+ // строку арендатора держит чужая транзакция дольше lockTimeout (хвост ревью доработок после 1.2.0): отчёт
757
+ // называет арендатора; с lockRetries после стольких проходов подряд он выбывает, и пачка идёт для остальных,
758
+ // иначе откатывается вся пачка и проход повторяется
759
+ if (failed && lockWait(cause)) {
760
+ if (!rep.locked.includes(tenant))
761
+ rep.locked.push(tenant);
762
+ const hits = (this.lockHits.get(tenant) ?? 0) + 1;
763
+ if (this.lockRetries === 0 || hits < this.lockRetries) {
764
+ this.lockHits.set(tenant, hits);
765
+ throw e;
766
+ }
767
+ this.lockHits.delete(tenant);
768
+ await q.unsafe('rollback to savepoint letopis_projector');
769
+ await q.unsafe('release savepoint letopis_projector');
770
+ failed[tenant] = {
771
+ cursor: list[0].cursor || start,
772
+ code: '55P03',
773
+ message: `проектор ${this.name}: строку арендатора ${tenant} держит чужая транзакция — в течение ${passesGen(hits)} подряд дольше lockTimeout`,
774
+ };
775
+ continue;
776
+ }
726
777
  if (!failed || !out.owns(e) || !tenantFault(cause))
727
778
  throw e;
728
779
  await q.unsafe('rollback to savepoint letopis_projector');
@@ -737,6 +788,7 @@ export class Projector {
737
788
  }
738
789
  if (failed)
739
790
  await q.unsafe('release savepoint letopis_projector');
791
+ this.lockHits.delete(tenant);
740
792
  rep.created += out.stats.created;
741
793
  rep.updated += out.stats.updated;
742
794
  rep.deleted += out.stats.deleted;
@@ -775,10 +827,17 @@ export class Projector {
775
827
  const sc = this.scopes.get(row.tenant);
776
828
  return sc !== undefined && !failed?.[row.tenant] && row.op === 'reclass' && !row.moved && !sc.from.has(row.class) && row.class !== '#reset';
777
829
  }
778
- /** Класс результата в арендаторе — из его семейства into. */
830
+ /**
831
+ * Входит ли класс `cls` в классы `into` проектора у арендатора `tenant` — сами эти классы или их потомки. Служебный
832
+ * метод: им `out` проверяет, что `handle` пишет только в эти классы.
833
+ */
779
834
  intoOf(tenant, cls) {
780
835
  return this.scopes.get(tenant)?.into.has(cls) ?? false;
781
836
  }
837
+ /** Поля ключа класса into арендатора (пусто — класс без ключа). */
838
+ keyOf(tenant, cls) {
839
+ return this.scopes.get(tenant)?.keys.get(cls) ?? [];
840
+ }
782
841
  /**
783
842
  * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
784
843
  * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
@@ -1002,6 +1061,9 @@ export class Projector {
1002
1061
  */
1003
1062
  async startTenant(rep, tenant, auto) {
1004
1063
  for (;;) {
1064
+ // строку арендатора (его производные строки удаляются) держит чужая транзакция дольше lockTimeout — как занятая
1065
+ // блокировка проектора: run() повторит на следующем проходе, rebuild() подождёт (хвост ревью доработок после 1.2.0:
1066
+ // rebuild({ tenant, chunked: true }) отдавал сырую 55P03)
1005
1067
  const r = await this.tx(async (q) => {
1006
1068
  if (!(await this.lock(q)))
1007
1069
  return 'busy';
@@ -1033,6 +1095,12 @@ export class Projector {
1033
1095
  delete next.resync;
1034
1096
  await this.advance(q, meta.cursor, withFailed(next, failed));
1035
1097
  return 'ok';
1098
+ }).catch((e) => {
1099
+ if (!lockWait(e))
1100
+ throw e;
1101
+ if (!auto)
1102
+ rep.skipped++;
1103
+ return 'busy';
1036
1104
  });
1037
1105
  if (r !== 'busy' || auto) {
1038
1106
  if (r === 'busy')
@@ -1127,6 +1195,20 @@ export class Projector {
1127
1195
  this.tenantsPending = more;
1128
1196
  return more;
1129
1197
  }
1198
+ /** Повторять fn, пока строку держит чужая транзакция дольше lockTimeout (55P03): пауза 20 мс, проход — в skipped. */
1199
+ async untilFree(rep, fn) {
1200
+ for (;;) {
1201
+ try {
1202
+ return await fn();
1203
+ }
1204
+ catch (e) {
1205
+ if (!lockWait(e))
1206
+ throw e;
1207
+ rep.skipped++;
1208
+ }
1209
+ await new Promise((res) => setTimeout(res, 20));
1210
+ }
1211
+ }
1130
1212
  /** Ждать сигнал источников, остановку или запасной опрос. */
1131
1213
  sleep() {
1132
1214
  return new Promise((resolve) => {
@@ -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
  }