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.
- package/AGENT-CHEATSHEET.en.md +10 -5
- package/AGENT-CHEATSHEET.md +14 -9
- package/CHANGELOG.md +159 -0
- package/README.en.md +119 -52
- package/README.md +107 -47
- 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 +12 -5
- 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 +30 -11
- package/dist/project.js +30 -4
- 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/tx.js +10 -1
- package/dist/types.d.ts +14 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +48 -25
- package/dist/up.js +89 -41
- 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/package.json +1 -1
- package/sql/10-core.sql +6 -1
- package/sql/60-write.sql +38 -18
- package/sql/80-auth.sql +39 -2
- package/sql/88-derived.sql +1 -0
- package/sql/90-time.sql +15 -5
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +1 -1
- 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
|
-
/**
|
|
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,21 +89,28 @@ 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 — без предела). Дольше —
|
|
@@ -280,8 +294,13 @@ export declare class Projector {
|
|
|
280
294
|
private ours;
|
|
281
295
|
/** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
|
|
282
296
|
private leaves;
|
|
283
|
-
/**
|
|
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
|
|
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() {
|
|
@@ -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}
|
|
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
|
-
/**
|
|
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 не складывает: её поля правят строку, и порядок таких правок важен.
|
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
|
}
|
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
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/** Курсор
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Разрешить `db.reset()` и `maintain({ now })` — для тестовых установок и стендов; по умолчанию запрещено. Значение
|
|
13
|
+
* задаётся при установке и хранится в схеме: каждый `up()` записывает его заново (план, §2.2).
|
|
14
|
+
*/
|
|
12
15
|
allowReset?: boolean;
|
|
13
16
|
/**
|
|
14
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* схеме:
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
140
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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 {};
|