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/admin.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LetopisError, fromDbError } from './errors.js';
|
|
2
2
|
import { runTx } from './tx.js';
|
|
3
3
|
import { CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
4
|
+
/** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
|
|
5
|
+
export const MAINTAIN_STOP = Symbol('letopis.maintain.stop');
|
|
4
6
|
const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
|
|
5
7
|
const one = async (a, text, params = []) => a.exec.run(async (q) => (await q.unsafe(text, params))[0]);
|
|
6
8
|
export async function verify(a, opts = {}) {
|
|
@@ -24,6 +26,9 @@ export async function maintain(a, opts = {}) {
|
|
|
24
26
|
const report = { skipped: false, sessions: 0, codes: 0, thinned: 0, batches: 0, more: false, queue: 0 };
|
|
25
27
|
const max = opts.batches ?? 100;
|
|
26
28
|
for (let i = 0; i < max; i++) {
|
|
29
|
+
// запуск по расписанию остановлен (close()): следующая пачка не начинается, отчёт — с more: true
|
|
30
|
+
if (i > 0 && opts[MAINTAIN_STOP]?.())
|
|
31
|
+
break;
|
|
27
32
|
// блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null
|
|
28
33
|
const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`, [now, opts.limit ?? 10_000]));
|
|
29
34
|
if (r === null) {
|
|
@@ -50,28 +55,49 @@ export async function maintain(a, opts = {}) {
|
|
|
50
55
|
export function queueWarning(queue) {
|
|
51
56
|
return queue > 0.5 ? `letopis: очередь сигналов PostgreSQL заполнена на ${(queue * 100).toFixed(0)} % — проверьте зависших слушателей` : null;
|
|
52
57
|
}
|
|
53
|
-
/**
|
|
58
|
+
/** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
|
|
59
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
60
|
+
/**
|
|
61
|
+
* Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
|
|
62
|
+
* суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
|
|
63
|
+
* обслуживание шло бы без перерыва.
|
|
64
|
+
*/
|
|
54
65
|
export function parseInterval(v) {
|
|
66
|
+
let ms = Number.NaN;
|
|
55
67
|
if (typeof v === 'number')
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
ms = v;
|
|
69
|
+
else if (typeof v === 'string') {
|
|
70
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
|
|
71
|
+
if (m)
|
|
72
|
+
ms = Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
75
|
+
const shown = typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v;
|
|
76
|
+
throw new LetopisError('invalid_data', `интервал ${shown}: число мс или строка '30s', '15m', '1h', '1d' — от 1 мс до 2³¹−1 мс (около 24 суток)`);
|
|
77
|
+
}
|
|
78
|
+
return ms;
|
|
62
79
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
|
|
82
|
+
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер, просит идущий запуск не
|
|
83
|
+
* начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
|
|
84
|
+
*/
|
|
85
|
+
export function scheduleMaintain(run, ms) {
|
|
86
|
+
let stopped = false;
|
|
87
|
+
let current = null;
|
|
67
88
|
const timer = setInterval(() => {
|
|
68
|
-
if (
|
|
89
|
+
if (current || stopped)
|
|
69
90
|
return;
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
current = run(() => stopped)
|
|
92
|
+
.then(() => { }, (e) => console.warn(`letopis: maintain по расписанию: ${e.message}`))
|
|
93
|
+
.finally(() => (current = null));
|
|
72
94
|
}, ms);
|
|
73
95
|
timer.unref?.();
|
|
74
|
-
return () =>
|
|
96
|
+
return async () => {
|
|
97
|
+
stopped = true;
|
|
98
|
+
clearInterval(timer);
|
|
99
|
+
await current;
|
|
100
|
+
};
|
|
75
101
|
}
|
|
76
102
|
/**
|
|
77
103
|
* Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
|
|
@@ -163,7 +189,7 @@ export function describe(a) {
|
|
|
163
189
|
})).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
|
|
164
190
|
return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
|
|
165
191
|
}
|
|
166
|
-
/** Описание модели текстом для языковых
|
|
192
|
+
/** Описание модели текстом для языковых моделей. */
|
|
167
193
|
export function describeText(d) {
|
|
168
194
|
const out = [`# letopis: модель схемы ${d.schema}`, '',
|
|
169
195
|
'Цепочка: db.Класс(фильтр).Связь().Класс().rows(); запись — create, update, upsert, delete; история — versions(), asOf(), withDeleted().', ''];
|
package/dist/auth.d.ts
CHANGED
|
@@ -15,6 +15,24 @@ export interface LoginResult {
|
|
|
15
15
|
account: string;
|
|
16
16
|
token: string;
|
|
17
17
|
}
|
|
18
|
+
/** Сведения о сессии по её токену (`db.auth.session()`). Время — строкой ISO 8601. */
|
|
19
|
+
export interface SessionInfo {
|
|
20
|
+
/** Аккаунт сессии. */
|
|
21
|
+
account: string;
|
|
22
|
+
/** Рабочий арендатор сессии: выбранный `auth.switch()` или собственный арендатор аккаунта. */
|
|
23
|
+
tenant: string;
|
|
24
|
+
/** Членство в рабочем арендаторе цело и арендатор включён; в собственном арендаторе — всегда true. */
|
|
25
|
+
member: boolean;
|
|
26
|
+
/** Когда сессия истечёт, если ею не пользоваться (подключение продлевает её само). */
|
|
27
|
+
expiresAt: string;
|
|
28
|
+
/** Время входа. */
|
|
29
|
+
loginAt: string;
|
|
30
|
+
/**
|
|
31
|
+
* Способы входа: вид креда, по которому вошли (`PASSWORD`, `APIKEY`, `KEYSECRET`, `OTP`, `TELEGRAM` и т. п.), `SERVICE` —
|
|
32
|
+
* сессию выпустил сервис (`sessionFor()`), `TOTP` — пройден второй фактор. У сессии от `createTenant()` список пуст.
|
|
33
|
+
*/
|
|
34
|
+
methods: string[];
|
|
35
|
+
}
|
|
18
36
|
/** Кред без секретного материала. */
|
|
19
37
|
export interface Credential {
|
|
20
38
|
id: string;
|
|
@@ -28,7 +46,7 @@ export interface Credential {
|
|
|
28
46
|
expires_at: string | null;
|
|
29
47
|
}
|
|
30
48
|
export interface AuthApi {
|
|
31
|
-
/** Пароль (и любой «логин/пароль»: kind — PASSWORD
|
|
49
|
+
/** Пароль (и любой «логин/пароль»: `kind` — `PASSWORD`, `EMAIL`, …): создать или заменить. Смена гасит остальные сессии аккаунта. */
|
|
32
50
|
setPassword(a: {
|
|
33
51
|
account: AccountRef;
|
|
34
52
|
identifier: string;
|
|
@@ -38,9 +56,9 @@ export interface AuthApi {
|
|
|
38
56
|
keepCurrent?: boolean;
|
|
39
57
|
}): Promise<string>;
|
|
40
58
|
/**
|
|
41
|
-
* Вход по паролю — для сервиса с правом auth.password и auth.session
|
|
42
|
-
* верного пароля база проверяет код TOTP аккаунта (право auth.totp
|
|
43
|
-
* с verifyTotp): верный — сессия со способами [kind, 'TOTP']
|
|
59
|
+
* Вход по паролю — для сервиса с правом `auth.password` и `auth.session`. Неудача — `null`. С `totp` после
|
|
60
|
+
* верного пароля база проверяет код TOTP аккаунта (право `auth.totp`; счётчик неудач и блокировка — общие
|
|
61
|
+
* с `verifyTotp()`): верный — сессия со способами `[kind, 'TOTP']`, неверный или повторный — `null`.
|
|
44
62
|
*/
|
|
45
63
|
verifyPassword(a: {
|
|
46
64
|
identifier: string;
|
|
@@ -70,7 +88,10 @@ export interface AuthApi {
|
|
|
70
88
|
id: string;
|
|
71
89
|
}>;
|
|
72
90
|
verifyKeySecret(key: string, secret: string): Promise<LoginResult | null>;
|
|
73
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Подключает второй фактор TOTP: выдаёт секрет (base32) и адрес `otpauth://` для QR-кода в приложении-аутентификаторе.
|
|
93
|
+
* Фактор включится после первой верной проверки кода — `verifyTotp()` или входа с `totp`.
|
|
94
|
+
*/
|
|
74
95
|
enrollTotp(a: {
|
|
75
96
|
account: AccountRef;
|
|
76
97
|
issuer?: string;
|
|
@@ -85,7 +106,7 @@ export interface AuthApi {
|
|
|
85
106
|
window?: number;
|
|
86
107
|
}): Promise<boolean>;
|
|
87
108
|
totpEnabled(account: AccountRef): Promise<boolean>;
|
|
88
|
-
/** Одноразовый код для доставки сервисом (право auth.otp). */
|
|
109
|
+
/** Одноразовый код для доставки сервисом (право `auth.otp`). */
|
|
89
110
|
issueOtp(a: {
|
|
90
111
|
account: AccountRef;
|
|
91
112
|
identifier: string;
|
|
@@ -96,13 +117,19 @@ export interface AuthApi {
|
|
|
96
117
|
}): Promise<{
|
|
97
118
|
code: string;
|
|
98
119
|
}>;
|
|
99
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Вход по одноразовому коду: верный код открывает новую сессию. Код сгорает после верного ввода и после исчерпания
|
|
122
|
+
* неверных попыток — их число задаёт `issueOtp({ maxAttempts })`.
|
|
123
|
+
*/
|
|
100
124
|
verifyOtp(a: {
|
|
101
125
|
identifier: string;
|
|
102
126
|
code: string;
|
|
103
127
|
kind?: string;
|
|
104
128
|
}): Promise<LoginResult | null>;
|
|
105
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Привязывает к аккаунту внешнюю учётную запись — например, для входа через Telegram, Google или SSO. Входят по привязке
|
|
131
|
+
* через `lookup()`; токен внешнего сервиса ваш сервис проверяет сам.
|
|
132
|
+
*/
|
|
106
133
|
link(a: {
|
|
107
134
|
account: AccountRef;
|
|
108
135
|
kind: string;
|
|
@@ -110,7 +137,11 @@ export interface AuthApi {
|
|
|
110
137
|
meta?: Record<string, unknown>;
|
|
111
138
|
confirmed?: boolean;
|
|
112
139
|
}): Promise<string>;
|
|
113
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Вход через привязанную внешнюю учётную запись: находит аккаунт по виду и id у внешнего сервиса и открывает ему новую
|
|
142
|
+
* сессию. Вызывает сервис с правами `auth.identity` и `auth.session`, когда уже сам проверил токен внешнего сервиса;
|
|
143
|
+
* `totp` — как у `verifyPassword()`.
|
|
144
|
+
*/
|
|
114
145
|
lookup(a: {
|
|
115
146
|
kind: string;
|
|
116
147
|
identifier: string;
|
|
@@ -118,36 +149,48 @@ export interface AuthApi {
|
|
|
118
149
|
ttl?: number;
|
|
119
150
|
totp?: string;
|
|
120
151
|
}): Promise<LoginResult | null>;
|
|
121
|
-
/**
|
|
152
|
+
/** Способы входа (креды) аккаунта — без хэшей и секретов. */
|
|
122
153
|
credentials(account: AccountRef): Promise<Credential[]>;
|
|
123
154
|
/**
|
|
124
155
|
* Удалить кред. Ключ API и пара ключ + секрет гасят только свои сессии, прочие креды — остальные сессии
|
|
125
|
-
* аккаунта; текущая гаснет, если keepCurrent: false
|
|
126
|
-
* действующий ключ сервиса не отзывается (acl_denied).
|
|
156
|
+
* аккаунта; текущая гаснет, если `keepCurrent: false`. Свои ключи сервис отзывает сам; последний
|
|
157
|
+
* действующий ключ сервиса не отзывается (`acl_denied`).
|
|
127
158
|
*/
|
|
128
159
|
revokeCredential(id: string, opts?: {
|
|
129
160
|
keepCurrent?: boolean;
|
|
130
161
|
}): Promise<boolean>;
|
|
131
|
-
/** Признаки креда confirmed и enabled (право auth.credential). */
|
|
162
|
+
/** Признаки креда `confirmed` и `enabled` (право `auth.credential`). */
|
|
132
163
|
setFlags(id: string, flags: {
|
|
133
164
|
confirmed?: boolean;
|
|
134
165
|
enabled?: boolean;
|
|
135
166
|
}): Promise<boolean>;
|
|
136
|
-
/**
|
|
167
|
+
/** Сессия пользователя для другого процесса (право `auth.session`); срок — `ttl` в секундах, по умолчанию 7 суток. */
|
|
137
168
|
sessionFor(account: AccountRef, opts?: {
|
|
138
169
|
ttl?: number;
|
|
139
170
|
}): Promise<string>;
|
|
171
|
+
/**
|
|
172
|
+
* Сведения о сессии по её токену — для сервиса, который держит токен пользователя (например, в cookie): чья это
|
|
173
|
+
* сессия, в каком арендаторе она работает, цело ли там членство, когда истечёт, когда и как был вход. Подключение
|
|
174
|
+
* по этому токену не открывается, срок сессии не продлевается. Вызывает сервис или системный администратор
|
|
175
|
+
* (право `auth.session`).
|
|
176
|
+
* @param token Токен сессии пользователя — тот, что выдали вход или `sessionFor()`.
|
|
177
|
+
* @returns Сведения о сессии или `null`: токен неверный, сессия погашена или истекла, аккаунт отключён — причина
|
|
178
|
+
* не различается.
|
|
179
|
+
* @throws LetopisError `acl_denied` — у вызывающего нет права `auth.session` (например, это обычный пользователь);
|
|
180
|
+
* `no_session` — у подключения нет действующей сессии.
|
|
181
|
+
*/
|
|
182
|
+
session(token: string): Promise<SessionInfo | null>;
|
|
140
183
|
/** Продлить текущую сессию; новый срок — от сейчас. */
|
|
141
184
|
refresh(opts?: {
|
|
142
185
|
ttl?: number;
|
|
143
186
|
}): Promise<string>;
|
|
144
187
|
/** Выход: погасить текущую сессию. */
|
|
145
188
|
revoke(): Promise<boolean>;
|
|
146
|
-
/** Выход везде: все сессии аккаунта (чужого — с правом auth.session). */
|
|
189
|
+
/** Выход везде: все сессии аккаунта (чужого — с правом `auth.session`). */
|
|
147
190
|
revokeAll(account?: AccountRef): Promise<number>;
|
|
148
191
|
/** Переключить арендатора сессии: собственный или по членству. */
|
|
149
192
|
switch(tenant: string): Promise<void>;
|
|
150
|
-
/** Арендаторы, доступные аккаунту сессии: собственный (owner) и по членству. */
|
|
193
|
+
/** Арендаторы, доступные аккаунту сессии: собственный (`owner`) и по членству. */
|
|
151
194
|
tenants(): Promise<{
|
|
152
195
|
tenant: string;
|
|
153
196
|
roles: string[];
|
|
@@ -155,7 +198,8 @@ export interface AuthApi {
|
|
|
155
198
|
/**
|
|
156
199
|
* Удалить аккаунт (этап 6; accounts.purge 0.21): системный администратор или владелец его
|
|
157
200
|
* арендатора; себя и последнего владельца арендатора — нельзя. Данные арендатора стираются,
|
|
158
|
-
* владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются.
|
|
201
|
+
* владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются. Владелец стирает
|
|
202
|
+
* и арендатора, в котором работает: его сессия возвращается в личный арендатор.
|
|
159
203
|
*/
|
|
160
204
|
purgeAccount(account: AccountRef): Promise<{
|
|
161
205
|
account: string;
|
package/dist/auth.js
CHANGED
|
@@ -27,7 +27,12 @@ export async function verifyHash(password, stored) {
|
|
|
27
27
|
if (algo !== 'scrypt' || !hashB64)
|
|
28
28
|
return false;
|
|
29
29
|
const want = Buffer.from(hashB64, 'base64');
|
|
30
|
-
|
|
30
|
+
// N, r, p — из строки хэша (подходят и хэши, импортированные из других систем), память — не больше 64 МБ: хэш,
|
|
31
|
+
// которому нужно больше, или с недопустимыми параметрами scrypt Node.js отвергает — это понятная ошибка, а не «неверный
|
|
32
|
+
// пароль» (false). Текст Node.js в сообщение не идёт: он зависит от сборки OpenSSL
|
|
33
|
+
const got = await scryptAsync(password, Buffer.from(saltB64, 'base64'), want.length, { N: Number(N), r: Number(r), p: Number(p), maxmem: SCRYPT.maxmem }).catch(() => {
|
|
34
|
+
throw new LetopisError('invalid_data', `хэш пароля: параметры scrypt N=${N}, r=${r}, p=${p} недопустимы или требуют больше 64 МБ памяти`);
|
|
35
|
+
});
|
|
31
36
|
return got.length === want.length && timingSafeEqual(got, want);
|
|
32
37
|
}
|
|
33
38
|
/** Холостая проверка: «идентификатора нет» отвечает не быстрее, чем «пароль неверен». */
|
|
@@ -178,6 +183,11 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
|
178
183
|
async sessionFor(account, opts) {
|
|
179
184
|
return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
|
|
180
185
|
},
|
|
186
|
+
async session(token) {
|
|
187
|
+
const r = (await one(`select ${S}.auth_session_info($1) as r`, [token])).r;
|
|
188
|
+
// jsonb хранит ключи в своём порядке — объект собирается в порядке описания SessionInfo
|
|
189
|
+
return r && { account: r.account, tenant: r.tenant, member: r.member, expiresAt: r.expiresAt, loginAt: r.loginAt, methods: r.methods };
|
|
190
|
+
},
|
|
181
191
|
async refresh(opts) {
|
|
182
192
|
return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
|
|
183
193
|
},
|
|
@@ -198,7 +208,11 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
|
198
208
|
return rows.map((r) => ({ tenant: r.tenant, roles: r.roles }));
|
|
199
209
|
},
|
|
200
210
|
async purgeAccount(account) {
|
|
201
|
-
|
|
211
|
+
const r = (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
|
|
212
|
+
// стёрт арендатор, в котором работала сессия корня: база вернула её в личный арендатор — перечитать
|
|
213
|
+
if (canSwitch)
|
|
214
|
+
await onSwitch();
|
|
215
|
+
return r;
|
|
202
216
|
},
|
|
203
217
|
};
|
|
204
218
|
}
|
package/dist/cache.d.ts
CHANGED
|
@@ -4,7 +4,11 @@ export declare const ACL_CLASSES: ReadonlySet<string>;
|
|
|
4
4
|
export interface CacheOptions {
|
|
5
5
|
/** Срок ответа по умолчанию, секунды (60). */
|
|
6
6
|
ttl?: number;
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Включает кэш, даже когда подключение не слушает сигналы базы: `connect({ listen: false })` (например, за PgBouncer в
|
|
9
|
+
* режиме транзакций) или установка с `up({ signals: 'none' })`; без опции кэш тогда выключен. Такой ответ живёт до
|
|
10
|
+
* конца срока: записи других процессов его не сбрасывают, записи этого подключения — сбрасывают (§2.12).
|
|
11
|
+
*/
|
|
8
12
|
ttlOnly?: boolean;
|
|
9
13
|
/** Не больше ответов в памяти (10 000): старые вытесняются. */
|
|
10
14
|
max?: number;
|
package/dist/chain.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface ChainCtx {
|
|
|
32
32
|
/** План в очереди батча; steps обновляется по мере роста цепочки. */
|
|
33
33
|
export interface BatchEntry {
|
|
34
34
|
steps: Step[];
|
|
35
|
+
/** Модификатор выборки, поставленный сразу после последнего глагола плана: run() откажет (`afterVerbError`). */
|
|
36
|
+
loose?: {
|
|
37
|
+
name: string;
|
|
38
|
+
verb: string;
|
|
39
|
+
};
|
|
35
40
|
}
|
|
36
41
|
/** Ссылка цепочки на очередь: первый глагол регистрирует план, следующие — обновляют. */
|
|
37
42
|
export interface BatchRef {
|
|
@@ -47,9 +52,16 @@ export type Batch = {
|
|
|
47
52
|
[className: string]: StepFn;
|
|
48
53
|
};
|
|
49
54
|
export interface ChainCore {
|
|
50
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные ссылками; приходит объектом, где ключ — имя
|
|
57
|
+
* шага (или имя из `alias()`), а значение — строка этого шага: `{ Shop: …, Product: … }`. Если к объекту последнего
|
|
58
|
+
* шага ведут несколько путей, придут все.
|
|
59
|
+
*/
|
|
51
60
|
paths(): Promise<Path[]>;
|
|
52
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Строки объектов последнего шага — каждый объект один раз, сколько бы путей к нему ни вело. Если цепочка кончается
|
|
63
|
+
* глаголом записи — строки этого глагола.
|
|
64
|
+
*/
|
|
53
65
|
rows(): Promise<Row[]>;
|
|
54
66
|
first(): Promise<Row | null>;
|
|
55
67
|
ids(): Promise<string[]>;
|
|
@@ -68,41 +80,92 @@ export interface ChainCore {
|
|
|
68
80
|
diff(opts?: DiffOptions): Promise<DiffResult[]>;
|
|
69
81
|
/** Кто, когда и почему последним изменил каждое поле версии (по умолчанию текущей), план 1.1. */
|
|
70
82
|
blame(opts?: BlameOptions): Promise<BlameResult[]>;
|
|
71
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Сумма поля `'data.<путь>'` по объектам последнего шага: значения приводятся к числу, складывает база. `limit()` и
|
|
85
|
+
* `offset()` на итог не влияют; нет ни одного значения — `null`.
|
|
86
|
+
*/
|
|
72
87
|
sum(field: string): Promise<number | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Среднее значение поля `'data.<путь>'` по объектам последнего шага: значения приводятся к числу, считает база.
|
|
90
|
+
* `limit()` и `offset()` на итог не влияют; нет ни одного значения — `null`.
|
|
91
|
+
*/
|
|
73
92
|
avg(field: string): Promise<number | null>;
|
|
93
|
+
/**
|
|
94
|
+
* Наименьшее значение поля `'data.<путь>'` среди объектов последнего шага; считает база. Числа сравниваются как числа
|
|
95
|
+
* и приходят числом, даты — как даты, поле без описания в классе — как текст. `limit()` и `offset()` на итог не
|
|
96
|
+
* влияют; нет ни одного значения — `null`.
|
|
97
|
+
*/
|
|
74
98
|
min(field: string): Promise<unknown>;
|
|
99
|
+
/**
|
|
100
|
+
* Наибольшее значение поля `'data.<путь>'` среди объектов последнего шага; считает база. Числа сравниваются как числа
|
|
101
|
+
* и приходят числом, даты — как даты, поле без описания в классе — как текст. `limit()` и `offset()` на итог не
|
|
102
|
+
* влияют; нет ни одного значения — `null`.
|
|
103
|
+
*/
|
|
75
104
|
max(field: string): Promise<unknown>;
|
|
105
|
+
/**
|
|
106
|
+
* Сколько объектов последнего шага с каждым значением поля `'data.<путь>'`: объект «значение → число», объекты без
|
|
107
|
+
* значения — под ключом `'null'`. Перед `countBy()` `limit()` и `offset()` отбирают группы — от самых частых значений.
|
|
108
|
+
*/
|
|
76
109
|
countBy(field: string): Promise<Record<string, number>>;
|
|
110
|
+
/**
|
|
111
|
+
* Не больше n строк ответа: объектов последнего шага, у `paths()` — путей, у `versions()` — версий, перед `countBy()` —
|
|
112
|
+
* групп. До глагола сужает его цели. Сразу после глагола перед `rows`, `first`, `ids`, `count` и `paths` —
|
|
113
|
+
* `invalid_query`: глагол уже прошёл по всем целям, сужать нечего.
|
|
114
|
+
*/
|
|
77
115
|
limit(n: number): Chain;
|
|
116
|
+
/** Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. */
|
|
78
117
|
offset(n: number): Chain;
|
|
79
118
|
/** Сортировка по полю последнего шага: 'at' | 'rev' | 'id' | 'data.<путь>'. */
|
|
80
119
|
sort(field: string, dir?: 'asc' | 'desc' | boolean): Chain;
|
|
81
120
|
/** Срез «как было на момент T»: версии журнала. */
|
|
82
121
|
asOf(t: string | Date): Chain;
|
|
83
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Постраничный вывод по курсору: ответ начинается строго после строки, на которой кончилась прошлая страница, в
|
|
124
|
+
* порядке `sort()` (без `sort()` — ошибка `invalid_query`). Курсор этой строки даёт `cursorOf(строка, поле)` с тем же
|
|
125
|
+
* полем, что в `sort()`.
|
|
126
|
+
*/
|
|
84
127
|
after(cursor: Cursor): Chain;
|
|
85
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Последний шаг находит и удалённые объекты. Удалённый объект приходит надгробием — своей последней версией, версией
|
|
130
|
+
* удаления, — с `$deleted: true`.
|
|
131
|
+
*/
|
|
86
132
|
withDeleted(): Chain;
|
|
87
133
|
/** Дети любой глубины на переходе к тому же классу; $depth в строке. */
|
|
88
134
|
deep(max?: number): Chain;
|
|
89
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Последний шаг находит объекты только своего класса — без классов-наследников (`extends` в модели), которых без
|
|
137
|
+
* `exact()` находит тоже.
|
|
138
|
+
*/
|
|
90
139
|
exact(): Chain;
|
|
91
140
|
/** Ключ текущего шага в путях. */
|
|
92
141
|
alias(name: string): Chain;
|
|
93
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Теги строки. В чтении и перед глаголами — отбор: строка или список — строки со всеми этими тегами (пустой список
|
|
144
|
+
* подходит любой строке), `has`, `hasAny`, `hasAll` — оператором. Перед `create()` и `upsert()` — теги новой строки
|
|
145
|
+
* (строка или список). Перед `update()` строка или список и отбирают, и заменяют теги целиком, а оператор только
|
|
146
|
+
* отбирает.
|
|
147
|
+
*/
|
|
94
148
|
tags(v: string | string[] | object): Chain;
|
|
95
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Владелец строк последнего шага (поле `owner`). В чтении и перед `update()`, `delete()` и другими глаголами — отбор:
|
|
151
|
+
* только строки этого владельца; перед `create()` и `upsert()` — владелец новой строки (существующей строке `upsert()`
|
|
152
|
+
* владельца не меняет).
|
|
153
|
+
*/
|
|
96
154
|
owner(v: string | {
|
|
97
155
|
id: string;
|
|
98
156
|
}): Chain;
|
|
99
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Добавляет в цепочку шаги из строки ответа (шаг её класса с её id) или из ещё не исполненной цепочки без глаголов
|
|
159
|
+
* записи (все её шаги как есть); `limit()`, `sort()`, `asOf()` и другие модификаторы всей цепочки не переносятся. Та
|
|
160
|
+
* же цепочка (та же переменная) второй раз в одном пути шагов не добавляет, а возвращает к своему последнему шагу:
|
|
161
|
+
* следующий шаг пойдёт от него.
|
|
162
|
+
*/
|
|
100
163
|
entity(x: Chain | Row): Chain;
|
|
101
164
|
/** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
|
|
102
165
|
create(data?: Record<string, unknown>, opts?: {
|
|
103
166
|
reason?: string;
|
|
104
167
|
}): Chain;
|
|
105
|
-
/** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } —
|
|
168
|
+
/** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — с проверкой версии. */
|
|
106
169
|
update(patch?: Record<string, unknown>, opts?: {
|
|
107
170
|
rev?: number;
|
|
108
171
|
reason?: string;
|
|
@@ -111,7 +174,11 @@ export interface ChainCore {
|
|
|
111
174
|
upsert(data: Record<string, unknown>, opts?: {
|
|
112
175
|
reason?: string;
|
|
113
176
|
}): Chain;
|
|
114
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Удаляет цели шага; строки, которые на них ссылаются, база удаляет вместе с ними, отвязывает или не даёт удалить цель —
|
|
179
|
+
* как задано в `onDelete` их ссылок. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
|
|
180
|
+
* вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет.
|
|
181
|
+
*/
|
|
115
182
|
delete(opts?: {
|
|
116
183
|
confirm?: boolean;
|
|
117
184
|
reason?: string;
|
|
@@ -130,12 +197,19 @@ export interface ChainCore {
|
|
|
130
197
|
restore(opts?: {
|
|
131
198
|
reason?: string;
|
|
132
199
|
}): Chain;
|
|
133
|
-
/**
|
|
200
|
+
/**
|
|
201
|
+
* Стирает историю удалённых объектов шага: все их версии в журнале, от каждого остаётся одна запись `purge`. Удалённые
|
|
202
|
+
* объекты шаг находит сам, без `withDeleted()`. Без `{ confirm: true }` — предпросмотр: ничего не стирается, терминал
|
|
203
|
+
* вернёт надгробия с `$versions` — сколько версий сотрётся.
|
|
204
|
+
*/
|
|
134
205
|
purge(opts?: {
|
|
135
206
|
confirm?: boolean;
|
|
136
207
|
reason?: string;
|
|
137
208
|
}): Chain;
|
|
138
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Новое значение ключа объектов шага. id вычисляется из ключа, поэтому объект получает новый id: ссылки других строк
|
|
211
|
+
* на него переводятся на новый id, а журнал связывает старый id с новым.
|
|
212
|
+
*/
|
|
139
213
|
rekey(patch: Record<string, unknown>, opts?: {
|
|
140
214
|
reason?: string;
|
|
141
215
|
}): Chain;
|
|
@@ -145,8 +219,10 @@ export interface ChainCore {
|
|
|
145
219
|
}): Chain;
|
|
146
220
|
}
|
|
147
221
|
/**
|
|
148
|
-
* Шаг
|
|
149
|
-
*
|
|
222
|
+
* Шаг цепочки — имя класса, его алиаса или роли с необязательным фильтром: `db.Shop(id).Product({ price: gt(1000) })`;
|
|
223
|
+
* каждый следующий шаг переходит по ссылкам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
|
|
224
|
+
* роли без вызова задаёт ссылку записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественной ссылки ещё
|
|
225
|
+
* `.Роль.add(…)` и `.Роль.remove(…)`.
|
|
150
226
|
*/
|
|
151
227
|
export type StepFn = ((filter?: Filter) => Chain) & {
|
|
152
228
|
set(value: unknown): Chain;
|
package/dist/chain.js
CHANGED
|
@@ -132,6 +132,27 @@ async function read(ctx, steps, mods, mode, agg) {
|
|
|
132
132
|
const ev = mode === 'countPaths' ? 'count' : mode;
|
|
133
133
|
return exec(ctx, q, ev, steps, mods).then((res) => ({ q, res }));
|
|
134
134
|
}
|
|
135
|
+
/** Модификатор выборки, заданный в mods, — именем метода цепочки; forUpdate() отказывает сам (lock_unsupported). */
|
|
136
|
+
function modName(mods) {
|
|
137
|
+
const key = Object.keys(mods).find((k) => mods[k] !== undefined && k !== 'forUpdate');
|
|
138
|
+
return key === 'order' || key === 'desc' ? 'sort' : key;
|
|
139
|
+
}
|
|
140
|
+
function afterVerbError(name, verb) {
|
|
141
|
+
return new LetopisError('invalid_query', `${name}() после ${verb}() ни на что не действует: глагол проходит по всем целям шага, а ответ — его строки. ` +
|
|
142
|
+
`Цели сужают limit(), offset() и sort() до глагола: db.Класс(…).limit(n).${verb}(…)`);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Терминалы rows, first, ids, count и paths цепочки, которая кончается глаголом, отдают строки самой правки.
|
|
146
|
+
* Модификатор выборки между глаголом и терминалом ни на что не подействовал бы: limit резал бы только ответ, хотя
|
|
147
|
+
* правка прошла по всем целям, остальные молча пропускались. Поэтому — отказ до записи. После глагола модификатор
|
|
148
|
+
* относится к чтению за ним (шаг класса, versions(), diff(), blame(), агрегаты) или сужает цели следующего глагола.
|
|
149
|
+
*/
|
|
150
|
+
function checkAfterVerb(steps, mods) {
|
|
151
|
+
const op = steps[steps.length - 1]?.op;
|
|
152
|
+
const name = op ? modName(mods) : undefined;
|
|
153
|
+
if (op && name)
|
|
154
|
+
throw afterVerbError(name, op.kind);
|
|
155
|
+
}
|
|
135
156
|
/**
|
|
136
157
|
* Цепочка с глаголами: весь план одной транзакцией (§2.5). Результат приводится к виду ответа
|
|
137
158
|
* чтения (строки rows/paths, id, n, агрегаты), чтобы терминалы разбирали его одинаково.
|
|
@@ -279,6 +300,13 @@ function classProp(ctx, name, found, ownerSteps, mods, nodes) {
|
|
|
279
300
|
});
|
|
280
301
|
}
|
|
281
302
|
export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
303
|
+
// батч: терминала нет, план исполнит run() — он и откажет, если цепочка кончилась модификатором после глагола
|
|
304
|
+
// (как checkAfterVerb вне батча); следующий шаг или глагол забирает модификатор себе, и отметка снимается
|
|
305
|
+
if (ctx.batch?.entry) {
|
|
306
|
+
const op = steps[steps.length - 1]?.op;
|
|
307
|
+
const name = op ? modName(mods) : undefined;
|
|
308
|
+
ctx.batch.entry.loose = op && name ? { name, verb: op.kind } : undefined;
|
|
309
|
+
}
|
|
282
310
|
const withLast = (patch) => {
|
|
283
311
|
const lastOp = steps[steps.length - 1]?.op;
|
|
284
312
|
if (lastOp)
|
|
@@ -369,21 +397,32 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
|
|
|
369
397
|
return entityStep;
|
|
370
398
|
case 'paths':
|
|
371
399
|
return async () => {
|
|
400
|
+
checkAfterVerb(steps, mods);
|
|
372
401
|
// q === null — пути из результата глагола: строки уже готовы
|
|
373
402
|
const { q, res } = await read(ctx, steps, mods, 'paths');
|
|
374
403
|
return res.map((r) => Object.fromEntries(Object.entries(r.path).map(([k, v]) => [k, q === null ? v : toRow(v)])));
|
|
375
404
|
};
|
|
376
405
|
case 'rows':
|
|
377
|
-
return async () =>
|
|
406
|
+
return async () => {
|
|
407
|
+
checkAfterVerb(steps, mods);
|
|
408
|
+
return (await read(ctx, steps, mods, 'rows')).res.map(rowOf);
|
|
409
|
+
};
|
|
378
410
|
case 'first':
|
|
379
411
|
return async () => {
|
|
412
|
+
checkAfterVerb(steps, mods);
|
|
380
413
|
const { res } = await read(ctx, steps, { ...mods, limit: 1 }, 'rows');
|
|
381
414
|
return res[0] ? rowOf(res[0]) : null;
|
|
382
415
|
};
|
|
383
416
|
case 'ids':
|
|
384
|
-
return async () =>
|
|
417
|
+
return async () => {
|
|
418
|
+
checkAfterVerb(steps, mods);
|
|
419
|
+
return (await read(ctx, steps, mods, 'ids')).res.map((r) => r.id);
|
|
420
|
+
};
|
|
385
421
|
case 'count':
|
|
386
|
-
return async (opts) =>
|
|
422
|
+
return async (opts) => {
|
|
423
|
+
checkAfterVerb(steps, mods);
|
|
424
|
+
return (await read(ctx, steps, mods, opts?.paths ? 'countPaths' : 'count')).res[0].n;
|
|
425
|
+
};
|
|
387
426
|
case 'versions':
|
|
388
427
|
return async (opts) => {
|
|
389
428
|
warnNoHistory(steps, 'versions');
|
|
@@ -503,7 +542,11 @@ export function makeBatch(ctx, queue) {
|
|
|
503
542
|
switch (prop) {
|
|
504
543
|
case 'run':
|
|
505
544
|
return async () => {
|
|
506
|
-
const
|
|
545
|
+
const entries = queue.splice(0, queue.length);
|
|
546
|
+
const loose = entries.find((x) => x.loose)?.loose;
|
|
547
|
+
if (loose)
|
|
548
|
+
throw afterVerbError(loose.name, loose.verb);
|
|
549
|
+
const plans = entries.map((x) => x.steps);
|
|
507
550
|
for (const p of plans)
|
|
508
551
|
precheckPlan(ctx.reg, p);
|
|
509
552
|
if (!plans.length)
|
package/dist/cli.js
CHANGED
|
@@ -146,6 +146,12 @@ export async function main(argv) {
|
|
|
146
146
|
console.log(JSON.stringify(r));
|
|
147
147
|
return 0;
|
|
148
148
|
}
|
|
149
|
+
if (cmd === 'describe' || cmd === 'types') {
|
|
150
|
+
// обе команды читают реестр сессии: без действующей сессии (токен истёк, погашен, неверен или не задан) он пуст,
|
|
151
|
+
// и команда выдала бы пустой результат с кодом 0, а с --out затёрла бы им файл. acl.check ничего не пишет, а без
|
|
152
|
+
// сессии отказывает no_session — тогда выход с кодом 1, файл не тронут
|
|
153
|
+
await db.acl.check('letopis.cli');
|
|
154
|
+
}
|
|
149
155
|
if (cmd === 'describe') {
|
|
150
156
|
const d = db.describe();
|
|
151
157
|
const text = values.format === 'text' ? describeText(d) : JSON.stringify(d, null, 2);
|
package/dist/errors.d.ts
CHANGED
|
@@ -13,10 +13,10 @@ export declare class LetopisError extends Error {
|
|
|
13
13
|
readonly detail: unknown;
|
|
14
14
|
constructor(code: string, message: string, detail?: unknown);
|
|
15
15
|
}
|
|
16
|
+
/** Версия, в которой снято старое API (её берёт и контракт API — scripts/gen-api-contract.mjs). */
|
|
17
|
+
export declare const REMOVED_IN = "1.0.0";
|
|
16
18
|
/** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
|
|
17
19
|
export declare function removed(name: string, replacement: string): LetopisError;
|
|
18
|
-
/** Метод зарезервирован, но появится на следующем этапе плана. */
|
|
19
|
-
export declare function notYet(name: string, stage: number): LetopisError;
|
|
20
20
|
/** Данные не проходят описание класса (`invalid_data`). */
|
|
21
21
|
export declare class ValidationError extends LetopisError {
|
|
22
22
|
readonly issues: Issue[];
|