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.
- package/AGENT-CHEATSHEET.en.md +14 -6
- package/AGENT-CHEATSHEET.md +18 -10
- package/CHANGELOG.md +247 -0
- package/README.en.md +146 -58
- package/README.md +129 -51
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +42 -16
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/chain.d.ts +91 -15
- package/dist/chain.js +47 -4
- package/dist/cli.js +6 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +74 -45
- package/dist/index.js +18 -4
- package/dist/load.d.ts +10 -3
- package/dist/load.js +19 -4
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +4 -4
- package/dist/model.d.ts +4 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +44 -12
- package/dist/project.js +90 -8
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/sql.d.ts +4 -1
- package/dist/sql.js +12 -4
- package/dist/sync.js +11 -5
- package/dist/tx.js +10 -1
- package/dist/types.d.ts +14 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +65 -25
- package/dist/up.js +152 -40
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +17 -5
- package/dist/watch.js +40 -8
- package/dist/write.d.ts +3 -2
- package/dist/write.js +29 -10
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/sql/10-core.sql +6 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +77 -22
- package/sql/80-auth.sql +39 -2
- package/sql/88-derived.sql +82 -1
- package/sql/90-time.sql +75 -38
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +2 -0
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
|
|
36
|
+
* `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
|
|
37
|
+
* `like('100\\%')`.
|
|
38
|
+
*/
|
|
23
39
|
export declare const like: (s: string) => Op;
|
|
24
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
|
|
37
|
+
* `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
|
|
38
|
+
* `like('100\\%')`.
|
|
39
|
+
*/
|
|
24
40
|
export const like = (s) => op('like', s);
|
|
25
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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) => {
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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) => {
|
package/dist/registry.d.ts
CHANGED
|
@@ -68,7 +68,10 @@ export declare class Registry {
|
|
|
68
68
|
all(): ClassInfo[];
|
|
69
69
|
/** Класс и все его потомки (семейство). */
|
|
70
70
|
family(name: string): ClassInfo[];
|
|
71
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|