letopis 1.4.2 → 2.0.0
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.md +329 -281
- package/CHANGELOG.md +418 -0
- package/README.md +572 -1053
- package/dist/acl.d.ts +78 -1
- package/dist/acl.js +264 -13
- package/dist/admin.d.ts +10 -141
- package/dist/admin.js +24 -184
- package/dist/auth.d.ts +2 -2
- package/dist/auth.js +5 -15
- package/dist/cache.d.ts +8 -1
- package/dist/cache.js +30 -8
- package/dist/chain.d.ts +18 -41
- package/dist/chain.js +165 -203
- package/dist/cli.js +45 -90
- package/dist/demo/booking.d.ts +352 -36
- package/dist/describe.d.ts +47 -0
- package/dist/describe.js +195 -0
- package/dist/errors.d.ts +59 -13
- package/dist/errors.js +190 -29
- package/dist/index.d.ts +52 -70
- package/dist/index.js +189 -122
- package/dist/internal.d.ts +5 -0
- package/dist/jsonschema.js +18 -17
- package/dist/load.d.ts +20 -12
- package/dist/load.js +207 -181
- package/dist/mcp.d.ts +5 -7
- package/dist/mcp.js +168 -214
- package/dist/messages/access.d.ts +90 -0
- package/dist/messages/access.js +113 -0
- package/dist/messages/api.d.ts +171 -0
- package/dist/messages/api.js +215 -0
- package/dist/messages/boot.d.ts +201 -0
- package/dist/messages/boot.js +256 -0
- package/dist/messages/codes.d.ts +172 -0
- package/dist/messages/codes.js +173 -0
- package/dist/messages/index.d.ts +95 -0
- package/dist/messages/index.js +118 -0
- package/dist/messages/mcp.d.ts +306 -0
- package/dist/messages/mcp.js +387 -0
- package/dist/messages/model.d.ts +233 -0
- package/dist/messages/model.js +283 -0
- package/dist/messages/query.d.ts +131 -0
- package/dist/messages/query.js +158 -0
- package/dist/messages/sql-access.d.ts +70 -0
- package/dist/messages/sql-access.js +87 -0
- package/dist/messages/sql-core.d.ts +150 -0
- package/dist/messages/sql-core.js +181 -0
- package/dist/messages/sql-time.d.ts +71 -0
- package/dist/messages/sql-time.js +88 -0
- package/dist/messages/sql-write.d.ts +246 -0
- package/dist/messages/sql-write.js +270 -0
- package/dist/messages/time.d.ts +23 -0
- package/dist/messages/time.js +29 -0
- package/dist/messages/types.d.ts +27 -0
- package/dist/messages/types.js +5 -0
- package/dist/messages/write.d.ts +405 -0
- package/dist/messages/write.js +509 -0
- package/dist/model.d.ts +73 -60
- package/dist/model.js +20 -29
- package/dist/ops.d.ts +35 -0
- package/dist/ops.js +101 -0
- package/dist/pglite.d.ts +0 -9
- package/dist/pglite.js +3 -1
- package/dist/registry.d.ts +3 -19
- package/dist/registry.js +3 -16
- package/dist/signals.d.ts +1 -5
- package/dist/signals.js +1 -5
- package/dist/sql.d.ts +7 -1
- package/dist/sql.js +101 -85
- package/dist/sync.d.ts +9 -3
- package/dist/sync.js +40 -17
- package/dist/tx.d.ts +11 -1
- package/dist/tx.js +32 -11
- package/dist/typed.d.ts +240 -45
- package/dist/types.d.ts +15 -90
- package/dist/types.js +12 -14
- package/dist/up.d.ts +53 -32
- package/dist/up.js +180 -74
- package/dist/uuid.js +5 -5
- package/dist/validate.d.ts +13 -1
- package/dist/validate.js +61 -29
- package/dist/watch.d.ts +5 -16
- package/dist/watch.js +23 -77
- package/dist/write.d.ts +9 -16
- package/dist/write.js +102 -185
- package/llms.txt +22 -25
- package/package.json +2 -9
- package/sql/10-core.sql +7 -50
- package/sql/15-errors.sql +64 -10
- package/sql/20-context.sql +7 -6
- package/sql/30-validate.sql +35 -27
- package/sql/40-class.sql +20 -114
- package/sql/50-acl.sql +8 -13
- package/sql/60-write.sql +220 -676
- package/sql/70-read.sql +11 -13
- package/sql/80-auth.sql +23 -27
- package/sql/90-time.sql +77 -944
- package/sql/95-seed.system.sql +7 -14
- package/sql/99-revision.sql +2 -1
- package/sql/README.md +32 -24
- package/sql/migrate/README.md +14 -0
- package/AGENT-CHEATSHEET.en.md +0 -488
- package/MIGRATION.md +0 -190
- package/README.en.md +0 -2910
- package/dist/import.d.ts +0 -185
- package/dist/import.js +0 -820
- package/dist/project.d.ts +0 -359
- package/dist/project.js +0 -1258
- package/sql/75-history.sql +0 -436
- package/sql/88-derived.sql +0 -1236
- package/sql/migrate/0006-changed-paths.sql +0 -3
- package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/admin.js
CHANGED
|
@@ -1,60 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Эксплуатация (план, этап 6, п. 7, 8): maintain и его запуск по расписанию. Всё проверяет база: функция владельца
|
|
3
|
+
* 90-time.sql сама сверяет права (letopis.maintain — системный администратор или администраторское подключение).
|
|
4
|
+
*/
|
|
5
|
+
import { lerr, lmsg, lwarn } from './errors.js';
|
|
2
6
|
import { runTx } from './tx.js';
|
|
3
|
-
import { CHAIN_METHODS, DB_METHODS } from './types.js';
|
|
4
|
-
/** Внутренняя опция maintain(): остановка запуска по расписанию — текущая пачка доделывается, следующая не начинается. */
|
|
5
|
-
export const MAINTAIN_STOP = Symbol('letopis.maintain.stop');
|
|
6
|
-
const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
|
|
7
|
-
const one = async (a, text, params = []) => a.exec.run(async (q) => (await q.unsafe(text, params))[0]);
|
|
8
|
-
export async function verify(a, opts = {}) {
|
|
9
|
-
const r = await one(a, `select ${a.S}.verify($1::boolean) as v`, [opts.data === true]);
|
|
10
|
-
return r.v;
|
|
11
|
-
}
|
|
12
7
|
/**
|
|
13
|
-
* Обслуживание: истёкшие
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* (skipped у всех). Блокировку держит другой процесс: до первой пачки — skipped; между пачками —
|
|
20
|
-
* вызов останавливается (продолжит тот процесс), отчёт — о сделанном, more: true.
|
|
8
|
+
* Обслуживание: истёкшие и погашенные сессии, одноразовые коды, строки-замки удалённых семейств и заполнение очереди
|
|
9
|
+
* сигналов — одной транзакцией. Из нескольких процессов работает один: maintain() базы первым делом берёт строку-замок
|
|
10
|
+
* обслуживания (lock_try: for update skip locked строки secret, которую посторонняя роль не займёт), и та снимается с
|
|
11
|
+
* фиксацией. Сеансовая блокировка за пулом в режиме транзакций (PgBouncer) оставалась бы на серверном соединении,
|
|
12
|
+
* которое пул отдаёт другим клиентам, — и обслуживание молча вставало бы (skipped у всех). Строку-замок держит другой
|
|
13
|
+
* процесс — skipped.
|
|
21
14
|
*/
|
|
22
|
-
export async function maintain(a
|
|
15
|
+
export async function maintain(a) {
|
|
23
16
|
if (a.exec.explicit)
|
|
24
|
-
throw
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (i > 0 && opts[MAINTAIN_STOP]?.())
|
|
31
|
-
break;
|
|
32
|
-
// блокировку берёт сама maintain() базы — строку-замок, которую посторонняя роль не займёт; занята — null.
|
|
33
|
-
// Истёкшие сессии и коды — только в первой пачке (план 2.0, 7.3 №4); схема, функции которой ещё не обновил up()
|
|
34
|
-
// (нет третьего аргумента, 42883), — прежний вызов: он чистит в каждой пачке
|
|
35
|
-
const call = (cleanup) => runTx(a.txc, (q) => q.unsafe(cleanup === null
|
|
36
|
-
? `select ${a.S}.maintain($1::text::timestamptz, $2::int) as r`
|
|
37
|
-
: `select ${a.S}.maintain($1::text::timestamptz, $2::int, $3::boolean) as r`, (cleanup === null ? [now, opts.limit ?? 10_000] : [now, opts.limit ?? 10_000, cleanup])));
|
|
38
|
-
const [{ r }] = await call(i === 0).catch((e) => {
|
|
39
|
-
if (e.code === '42883')
|
|
40
|
-
return call(null);
|
|
41
|
-
throw e;
|
|
42
|
-
});
|
|
43
|
-
if (r === null) {
|
|
44
|
-
if (i === 0)
|
|
45
|
-
return { ...report, skipped: true };
|
|
46
|
-
break;
|
|
47
|
-
}
|
|
48
|
-
const b = r;
|
|
49
|
-
report.sessions += b.sessions;
|
|
50
|
-
report.codes += b.codes;
|
|
51
|
-
report.thinned += b.thinned;
|
|
52
|
-
report.queue = Number(b.queue);
|
|
53
|
-
report.more = b.more;
|
|
54
|
-
report.batches++;
|
|
55
|
-
if (!b.more)
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
17
|
+
throw lerr('time.maintain_in_tx');
|
|
18
|
+
const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.maintain() as r`));
|
|
19
|
+
if (r === null)
|
|
20
|
+
return { skipped: true, sessions: 0, codes: 0, queue: 0 };
|
|
21
|
+
const b = r;
|
|
22
|
+
const report = { skipped: false, sessions: b.sessions, codes: b.codes, queue: Number(b.queue) };
|
|
58
23
|
const warn = queueWarning(report.queue);
|
|
59
24
|
if (warn)
|
|
60
25
|
console.warn(warn);
|
|
@@ -62,7 +27,7 @@ export async function maintain(a, opts = {}) {
|
|
|
62
27
|
}
|
|
63
28
|
/** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
|
|
64
29
|
export function queueWarning(queue) {
|
|
65
|
-
return queue > 0.5 ? `letopis:
|
|
30
|
+
return queue > 0.5 ? `letopis: ${lmsg('time.signal_queue', { pct: (queue * 100).toFixed(0) })}` : null;
|
|
66
31
|
}
|
|
67
32
|
/** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
|
|
68
33
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
@@ -82,14 +47,14 @@ export function parseInterval(v) {
|
|
|
82
47
|
}
|
|
83
48
|
if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
|
|
84
49
|
const shown = typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v;
|
|
85
|
-
throw
|
|
50
|
+
throw lerr('time.bad_interval', { value: shown });
|
|
86
51
|
}
|
|
87
52
|
return ms;
|
|
88
53
|
}
|
|
89
54
|
/**
|
|
90
55
|
* Запуск maintain() по расписанию (connect({ maintain: '1h' }); ms — уже проверенный интервал): ошибки — предупреждением,
|
|
91
|
-
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает
|
|
92
|
-
*
|
|
56
|
+
* следующий запуск не начинается, пока идёт прежний. Возвращает остановку: она снимает таймер и ждёт конца идущего
|
|
57
|
+
* запуска — close() закрывает пул после него, а не посреди транзакции.
|
|
93
58
|
*/
|
|
94
59
|
export function scheduleMaintain(run, ms) {
|
|
95
60
|
let stopped = false;
|
|
@@ -97,8 +62,8 @@ export function scheduleMaintain(run, ms) {
|
|
|
97
62
|
const timer = setInterval(() => {
|
|
98
63
|
if (current || stopped)
|
|
99
64
|
return;
|
|
100
|
-
current = run(
|
|
101
|
-
.then(() => { }, (e) =>
|
|
65
|
+
current = run()
|
|
66
|
+
.then(() => { }, (e) => lwarn('time.maintain_failed', { message: e.message }))
|
|
102
67
|
.finally(() => (current = null));
|
|
103
68
|
}, ms);
|
|
104
69
|
timer.unref?.();
|
|
@@ -108,128 +73,3 @@ export function scheduleMaintain(run, ms) {
|
|
|
108
73
|
await current;
|
|
109
74
|
};
|
|
110
75
|
}
|
|
111
|
-
/**
|
|
112
|
-
* Сброс (этап 6, п. 6). Предохранители — в базе: флаг allowReset установки, право letopis.reset,
|
|
113
|
-
* подтверждение именем схемы. tenant — пачками (длинная операция не держит горизонт подписок).
|
|
114
|
-
*/
|
|
115
|
-
export async function reset(a, opts) {
|
|
116
|
-
if (opts.level === 'tenant') {
|
|
117
|
-
if (a.exec.explicit)
|
|
118
|
-
throw new LetopisError('invalid_query', "reset({ level: 'tenant' }) — пачками в своих транзакциях, не внутри db.begin()");
|
|
119
|
-
let rows = 0;
|
|
120
|
-
let versions = 0;
|
|
121
|
-
for (;;) {
|
|
122
|
-
const [{ r }] = await runTx(a.txc, (q) => q.unsafe(`select ${a.S}.reset($1, $2, $3::uuid, 10000) as r`, [opts.level, opts.confirm, opts.tenant ?? null]));
|
|
123
|
-
const b = r;
|
|
124
|
-
rows += b.rows;
|
|
125
|
-
versions += b.versions;
|
|
126
|
-
if (b.done)
|
|
127
|
-
return { level: opts.level, tenant: b.tenant, rows, versions };
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
const r = await one(a, `select ${a.S}.reset($1, $2, $3::uuid) as r`, [opts.level, opts.confirm, opts.tenant ?? null]);
|
|
131
|
-
return r.r;
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Обрезка истории (этап 6, п. 3): версии строже момента before удаляются, текущая — никогда; на место
|
|
135
|
-
* версии 1 встаёт запись trim. target — имя класса (с потомками), id или список id, строка ответа.
|
|
136
|
-
*/
|
|
137
|
-
export async function trimHistory(a, target, before) {
|
|
138
|
-
const at = before instanceof Date ? before.toISOString() : before;
|
|
139
|
-
let ids = null;
|
|
140
|
-
let classes = null;
|
|
141
|
-
if (typeof target === 'string' && a.registry().has(target)) {
|
|
142
|
-
classes = a.registry().family(target).map((c) => c.name);
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
const list = Array.isArray(target) ? target : [target];
|
|
146
|
-
ids = list.map((x) => (typeof x === 'string' ? x : x.id));
|
|
147
|
-
}
|
|
148
|
-
const r = await one(a, `select ${a.S}.trim_history($1::uuid[], $2::text[], $3::text::timestamptz) as n`, [ids, classes, at]);
|
|
149
|
-
return r.n;
|
|
150
|
-
}
|
|
151
|
-
/** Новый ключ класса (этап 6, п. 7): строки семейства переносятся на новые id. Возвращает их число. */
|
|
152
|
-
export async function rekeyClass(a, cls, key) {
|
|
153
|
-
const name = a.registry().resolve(cls).name;
|
|
154
|
-
const r = await one(a, `select ${a.S}.rekey_class($1, $2::jsonb) as n`, [name, key]);
|
|
155
|
-
return r.n;
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Ремонт агрегатов (план 1.1, §2.4): поля класса и его потомков во всех арендаторах — заново из живых источников,
|
|
159
|
-
* под исключительными блокировками семейств целей и источников (запись в них ждёт). Версия пишется только там, где
|
|
160
|
-
* значение изменилось; возвращает число исправленных строк. Функция владельца — от его роли (set local role), как
|
|
161
|
-
* загрузчик; подключение проверил вызывающий (admin_required).
|
|
162
|
-
*/
|
|
163
|
-
export async function rebuildAggregates(env, cls) {
|
|
164
|
-
if (typeof cls !== 'string' || !cls)
|
|
165
|
-
throw new LetopisError('invalid_data', 'rebuildAggregates(класс): имя класса');
|
|
166
|
-
const role = `"${env.ownerRole.replace(/"/g, '""')}"`;
|
|
167
|
-
return env.sql.begin(async (q) => {
|
|
168
|
-
await q.unsafe(`set local role ${role}`);
|
|
169
|
-
await q.unsafe(`select set_config('letopis.reason', 'rebuildAggregates', true)`);
|
|
170
|
-
const [{ n }] = await q.unsafe(`select ${env.S}.rebuild_aggregates($1) as n`, [cls]);
|
|
171
|
-
return n;
|
|
172
|
-
}).catch((e) => {
|
|
173
|
-
throw fromDbError(e);
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
/** Машинное описание модели арендатора вместе с метаданными классов (этап 6, п. 12). */
|
|
177
|
-
export function describe(a) {
|
|
178
|
-
const classes = a.registry().all().map((c) => ({
|
|
179
|
-
name: c.name,
|
|
180
|
-
kind: c.kind,
|
|
181
|
-
...(c.alias ? { alias: c.alias } : {}),
|
|
182
|
-
abstract: c.abstract,
|
|
183
|
-
...(c.ancestors[1] ? { parent: c.ancestors[1] } : {}),
|
|
184
|
-
ancestors: c.ancestors,
|
|
185
|
-
system: SYSTEM_CLASSES.has(c.name),
|
|
186
|
-
fields: (c.schema.properties ?? {}),
|
|
187
|
-
required: (c.schema.required ?? []),
|
|
188
|
-
...(Array.isArray(c.schema.anyOf) ? { anyOf: c.schema.anyOf } : {}),
|
|
189
|
-
...(Array.isArray(c.schema.oneOf) ? { oneOf: c.schema.oneOf } : {}),
|
|
190
|
-
ends: c.ends,
|
|
191
|
-
key: c.key,
|
|
192
|
-
history: c.history,
|
|
193
|
-
ownerDefault: c.ownerDefault,
|
|
194
|
-
...(c.cache ? { cache: c.cache } : {}),
|
|
195
|
-
meta: c.meta,
|
|
196
|
-
...(c.derivedBy ? { derivedBy: c.derivedBy } : {}),
|
|
197
|
-
...(c.aggregate ? { aggregate: c.aggregate } : {}),
|
|
198
|
-
})).sort((x, y) => Number(x.system) - Number(y.system) || x.name.localeCompare(y.name));
|
|
199
|
-
return { schema: a.schema, tenant: a.tenant(), classes, methods: { chain: CHAIN_METHODS, db: DB_METHODS } };
|
|
200
|
-
}
|
|
201
|
-
/** Описание модели текстом для языковых моделей. */
|
|
202
|
-
export function describeText(d) {
|
|
203
|
-
const out = [`# letopis: модель схемы ${d.schema}`, '',
|
|
204
|
-
'Цепочка: db.Класс(фильтр).Связь().Класс().rows(); запись — create, update, upsert, delete; история — versions(), asOf(), withDeleted().', ''];
|
|
205
|
-
for (const c of d.classes) {
|
|
206
|
-
const title = typeof c.meta.title === 'string' ? ` — ${c.meta.title}` : '';
|
|
207
|
-
out.push(`## ${c.name}${c.alias ? ` (${c.alias})` : ''}${title}`);
|
|
208
|
-
out.push(`- вид: ${c.kind === 'hub' ? 'хаб (объект)' : 'связь'}${c.abstract ? ', абстрактный' : ''}${c.parent ? `, предок ${c.parent}` : ''}${c.system ? ', системный' : ''}`);
|
|
209
|
-
out.push(`- ключ: ${c.key.length ? c.key.join(', ') : 'нет (id выдаёт база)'}`);
|
|
210
|
-
const fields = Object.entries(c.fields).map(([k, v]) => {
|
|
211
|
-
const t = v.type;
|
|
212
|
-
return `${k}${c.required.includes(k) ? '*' : ''}: ${Array.isArray(t) ? t.join('|') : t ?? 'any'}`;
|
|
213
|
-
});
|
|
214
|
-
if (fields.length)
|
|
215
|
-
out.push(`- поля: ${fields.join(', ')}`);
|
|
216
|
-
if (c.anyOf)
|
|
217
|
-
out.push(`- условия anyOf (хотя бы одно): ${JSON.stringify(c.anyOf)}`);
|
|
218
|
-
if (c.oneOf)
|
|
219
|
-
out.push(`- условия oneOf (ровно одно): ${JSON.stringify(c.oneOf)}`);
|
|
220
|
-
const ends = Object.entries(c.ends).map(([k, v]) => {
|
|
221
|
-
const e = v;
|
|
222
|
-
return `${k}${e.required ? '*' : ''} → ${Array.isArray(e.class) ? e.class.join('|') : e.class}${e.many ? '[]' : ''}`;
|
|
223
|
-
});
|
|
224
|
-
if (ends.length)
|
|
225
|
-
out.push(`- концы: ${ends.join(', ')}`);
|
|
226
|
-
if (c.derivedBy)
|
|
227
|
-
out.push(`- производный: строки пишет проектор ${c.derivedBy}, приложение только читает`);
|
|
228
|
-
for (const [field, a] of Object.entries(c.aggregate ?? {})) {
|
|
229
|
-
const what = a.sum !== undefined ? `сумма ${a.sum}` : 'число';
|
|
230
|
-
out.push(`- агрегат ${field} = ${what} строк ${a.of} по концу ${a.via}${a.where ? `, условие ${JSON.stringify(a.where)}` : ''}; поле считает база, приложение его не пишет`);
|
|
231
|
-
}
|
|
232
|
-
out.push('');
|
|
233
|
-
}
|
|
234
|
-
return out.join('\n');
|
|
235
|
-
}
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Executor } from './tx.js';
|
|
2
|
-
/** Хэш пароля scrypt: `scrypt$N$r$p
|
|
2
|
+
/** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш`. */
|
|
3
3
|
export declare function hashPassword(password: string): Promise<string>;
|
|
4
4
|
/** Проверка пароля по хэшу scrypt; другие форматы не проверяются. */
|
|
5
5
|
export declare function verifyHash(password: string, stored: string): Promise<boolean>;
|
|
@@ -196,7 +196,7 @@ export interface AuthApi {
|
|
|
196
196
|
roles: string[];
|
|
197
197
|
}[]>;
|
|
198
198
|
/**
|
|
199
|
-
* Удалить
|
|
199
|
+
* Удалить аккаунт: системный администратор или владелец его
|
|
200
200
|
* арендатора; себя и последнего владельца арендатора — нельзя. Данные арендатора стираются,
|
|
201
201
|
* владение строками в чужих арендаторах переходит им, сессии гаснут, креды удаляются. Владелец стирает
|
|
202
202
|
* и арендатора, в котором работает: его сессия возвращается в личный арендатор.
|
package/dist/auth.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
|
-
import {
|
|
14
|
+
import { lerr } from './errors.js';
|
|
15
15
|
const scryptAsync = promisify(scrypt);
|
|
16
16
|
const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
17
17
|
const KEYLEN = 32;
|
|
18
|
-
/** Хэш пароля scrypt: `scrypt$N$r$p
|
|
18
|
+
/** Хэш пароля scrypt: `scrypt$N$r$p$соль$хэш`. */
|
|
19
19
|
export async function hashPassword(password) {
|
|
20
20
|
const salt = randomBytes(16);
|
|
21
21
|
const hash = await scryptAsync(password, salt, KEYLEN, SCRYPT);
|
|
@@ -31,7 +31,7 @@ export async function verifyHash(password, stored) {
|
|
|
31
31
|
// которому нужно больше, или с недопустимыми параметрами scrypt Node.js отвергает — это понятная ошибка, а не «неверный
|
|
32
32
|
// пароль» (false). Текст Node.js в сообщение не идёт: он зависит от сборки OpenSSL
|
|
33
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
|
|
34
|
+
throw lerr('access.bad_scrypt_params', { N, r, p });
|
|
35
35
|
});
|
|
36
36
|
return got.length === want.length && timingSafeEqual(got, want);
|
|
37
37
|
}
|
|
@@ -61,7 +61,7 @@ export function base32Decode(s) {
|
|
|
61
61
|
for (const ch of s.replace(/=+$/, '').toUpperCase()) {
|
|
62
62
|
const idx = B32.indexOf(ch);
|
|
63
63
|
if (idx < 0)
|
|
64
|
-
throw
|
|
64
|
+
throw lerr('access.bad_totp_secret');
|
|
65
65
|
value = (value << 5) | idx;
|
|
66
66
|
bits += 5;
|
|
67
67
|
if (bits >= 8) {
|
|
@@ -86,16 +86,6 @@ const accId = (a) => (typeof a === 'object' ? a.id : a);
|
|
|
86
86
|
const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
|
|
87
87
|
/** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
|
|
88
88
|
export function makeAuth(S, exec, onSwitch, canSwitch) {
|
|
89
|
-
const api = makeAuthCore(S, exec, onSwitch, canSwitch);
|
|
90
|
-
return new Proxy(api, {
|
|
91
|
-
get(t, p, r) {
|
|
92
|
-
if (p === 'sessions')
|
|
93
|
-
throw removed('db.auth.sessions(store)', 'сессии хранятся в базе: sessionFor, refresh, revoke, revokeAll');
|
|
94
|
-
return Reflect.get(t, p, r);
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
99
89
|
const one = async (text, params = []) => {
|
|
100
90
|
const rows = await exec.run((q) => q.unsafe(text, params));
|
|
101
91
|
return rows[0];
|
|
@@ -199,7 +189,7 @@ function makeAuthCore(S, exec, onSwitch, canSwitch) {
|
|
|
199
189
|
},
|
|
200
190
|
async switch(tenant) {
|
|
201
191
|
if (!canSwitch)
|
|
202
|
-
throw
|
|
192
|
+
throw lerr('access.switch_not_root');
|
|
203
193
|
await one(`select ${S}.auth_switch($1::uuid)`, [tenant]);
|
|
204
194
|
await onSwitch();
|
|
205
195
|
},
|
package/dist/cache.d.ts
CHANGED
|
@@ -68,6 +68,13 @@ export declare class ResultCache {
|
|
|
68
68
|
}): string;
|
|
69
69
|
/** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
|
|
70
70
|
token(classes: readonly string[], session: string, tenantId?: string | null): CacheToken;
|
|
71
|
+
/**
|
|
72
|
+
* Счётчик сессии заводит снимок запроса (token), а сигнал о гашении меняет только заведённые: снимок в полёте бывает
|
|
73
|
+
* лишь у сессии, которая читала через этот кэш. Гасят же в основном чужие сессии — вход и выход пользователей
|
|
74
|
+
* сервиса, — и счётчик на каждый тег копил бы карту до предела EPOCH_MAX (долгий прогон: вход и выход раз в секунду —
|
|
75
|
+
* 0,7–0,9 МБ/ч на подключение). Заведённый счётчик не удаляется: иначе снимок, взятый до гашения, сошёлся бы с нулём.
|
|
76
|
+
*/
|
|
77
|
+
private watchSession;
|
|
71
78
|
private pairCount;
|
|
72
79
|
get(key: string): unknown[] | undefined;
|
|
73
80
|
/** Положить ответ, если с момента снимка не было сигналов о его классах и сессии. */
|
|
@@ -76,7 +83,7 @@ export declare class ResultCache {
|
|
|
76
83
|
invalidate(classes: Iterable<string>): void;
|
|
77
84
|
/** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
|
|
78
85
|
private invalidateIn;
|
|
79
|
-
/** Сбросить ответы сессии (тег — начало sha256 токена). */
|
|
86
|
+
/** Сбросить ответы сессии (тег — начало sha256 токена); счётчик — только у заведённой снимком (watchSession). */
|
|
80
87
|
invalidateSession(tag: string): void;
|
|
81
88
|
/**
|
|
82
89
|
* Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
|
package/dist/cache.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* Ключ — текст запроса, параметры и тот, кто спрашивает (токен, актор, арендатор): под RLS один
|
|
6
6
|
* запрос разным пользователям отдаёт разные строки. Сброс:
|
|
7
7
|
* - свои записи — сразу после фиксации, по классам из letopis.changed (туда попадают и классы
|
|
8
|
-
*
|
|
8
|
+
* каскада и unset); запись через db.sql — весь кэш;
|
|
9
9
|
* - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
|
|
10
10
|
* сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
|
|
11
|
-
* opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class)
|
|
12
|
-
*
|
|
11
|
+
* opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) — весь кэш;
|
|
12
|
+
* `session:<16 hex>` — ответы погашенной сессии;
|
|
13
13
|
* - гонка запроса и сигнала: счётчики сигналов по классам, парам «класс, арендатор» и сессиям
|
|
14
|
-
* снимаются до запроса, ответ кладётся, только если они не изменились;
|
|
14
|
+
* снимаются до запроса, ответ кладётся, только если они не изменились; счётчик сессии заводит сам
|
|
15
|
+
* снимок — гашение сессии, которая через кэш не читала, следа в картах не оставляет;
|
|
15
16
|
* - (пере)подключение слушателя сигналов — весь кэш: сигналы за время обрыва потеряны; без слушателя
|
|
16
17
|
* кэш выключен (с ttlOnly — работает только по сроку);
|
|
17
18
|
* - срок (по умолчанию 60 с) — предохранитель; ответы истёкшей сессии не используются.
|
|
@@ -63,13 +64,32 @@ export class ResultCache {
|
|
|
63
64
|
/** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
|
|
64
65
|
token(classes, session, tenantId) {
|
|
65
66
|
const tenant = this.allTenants ? null : this.sig.tenantToken(tenantId);
|
|
67
|
+
// счётчик сессии — до снимка epoch: переполнение карты сдвигает epoch
|
|
68
|
+
const sessionCount = this.watchSession(session);
|
|
66
69
|
return {
|
|
67
70
|
epoch: this.epoch,
|
|
68
71
|
classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0, this.pairCount(c, tenant)]),
|
|
69
|
-
session:
|
|
72
|
+
session: sessionCount,
|
|
70
73
|
tenant,
|
|
71
74
|
};
|
|
72
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Счётчик сессии заводит снимок запроса (token), а сигнал о гашении меняет только заведённые: снимок в полёте бывает
|
|
78
|
+
* лишь у сессии, которая читала через этот кэш. Гасят же в основном чужие сессии — вход и выход пользователей
|
|
79
|
+
* сервиса, — и счётчик на каждый тег копил бы карту до предела EPOCH_MAX (долгий прогон: вход и выход раз в секунду —
|
|
80
|
+
* 0,7–0,9 МБ/ч на подключение). Заведённый счётчик не удаляется: иначе снимок, взятый до гашения, сошёлся бы с нулём.
|
|
81
|
+
*/
|
|
82
|
+
watchSession(tag) {
|
|
83
|
+
const n = this.sessionEpoch.get(tag);
|
|
84
|
+
if (n !== undefined)
|
|
85
|
+
return n;
|
|
86
|
+
if (this.sessionEpoch.size >= EPOCH_MAX) {
|
|
87
|
+
this.sessionEpoch.clear();
|
|
88
|
+
this.epoch++;
|
|
89
|
+
}
|
|
90
|
+
this.sessionEpoch.set(tag, 0);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
73
93
|
pairCount(cls, tenant) {
|
|
74
94
|
return (tenant === null ? this.anyEpoch.get(cls) : this.pairEpoch.get(`${cls}@${tenant}`)) ?? 0;
|
|
75
95
|
}
|
|
@@ -126,9 +146,11 @@ export class ResultCache {
|
|
|
126
146
|
if ((e.tenant === null || e.tenant === tenant) && e.classes.includes(cls))
|
|
127
147
|
this.map.delete(key);
|
|
128
148
|
}
|
|
129
|
-
/** Сбросить ответы сессии (тег — начало sha256 токена). */
|
|
149
|
+
/** Сбросить ответы сессии (тег — начало sha256 токена); счётчик — только у заведённой снимком (watchSession). */
|
|
130
150
|
invalidateSession(tag) {
|
|
131
|
-
this.
|
|
151
|
+
const n = this.sessionEpoch.get(tag);
|
|
152
|
+
if (n !== undefined)
|
|
153
|
+
this.sessionEpoch.set(tag, n + 1);
|
|
132
154
|
for (const [k, e] of this.map)
|
|
133
155
|
if (e.session === tag)
|
|
134
156
|
this.map.delete(k);
|
|
@@ -161,7 +183,7 @@ export class ResultCache {
|
|
|
161
183
|
// класса нет в реестрах процесса — и ответов с ним нет
|
|
162
184
|
return;
|
|
163
185
|
default:
|
|
164
|
-
//
|
|
186
|
+
// переподключение слушателя, opaque без ключа, нагрузка не по режиму
|
|
165
187
|
return this.clear();
|
|
166
188
|
}
|
|
167
189
|
}
|
package/dist/chain.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ResultCache } from './cache.js';
|
|
|
2
2
|
import type { ClassInfo, Registry } from './registry.js';
|
|
3
3
|
import { type Step } from './sql.js';
|
|
4
4
|
import type { Executor } from './tx.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { ChainMods, Cursor, Filter, Path, QueryEvent, Row } from './types.js';
|
|
6
6
|
export interface ChainCtx {
|
|
7
7
|
readonly reg: Registry;
|
|
8
8
|
readonly schema: string;
|
|
@@ -69,17 +69,8 @@ export interface ChainCore {
|
|
|
69
69
|
count(opts?: {
|
|
70
70
|
paths?: boolean;
|
|
71
71
|
}): Promise<number>;
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
* { follow: true } — и через смену id при reclass и rekey, по времени.
|
|
75
|
-
*/
|
|
76
|
-
versions(opts?: {
|
|
77
|
-
follow?: boolean;
|
|
78
|
-
}): Promise<Row[]>;
|
|
79
|
-
/** Что изменилось между двумя версиями или моментами (по умолчанию — предыдущая версия → последняя), план 1.1. */
|
|
80
|
-
diff(opts?: DiffOptions): Promise<DiffResult[]>;
|
|
81
|
-
/** Кто, когда и почему последним изменил каждое поле версии (по умолчанию текущей), план 1.1. */
|
|
82
|
-
blame(opts?: BlameOptions): Promise<BlameResult[]>;
|
|
72
|
+
/** Все версии сущностей последнего шага (надгробия — $deleted), по id и номеру версии. */
|
|
73
|
+
versions(): Promise<Row[]>;
|
|
83
74
|
/**
|
|
84
75
|
* Сумма поля `'data.<путь>'` по объектам последнего шага: значения приводятся к числу, складывает база. `limit()` и
|
|
85
76
|
* `offset()` на итог не влияют; нет ни одного значения — `null`.
|
|
@@ -123,7 +114,7 @@ export interface ChainCore {
|
|
|
123
114
|
* Постраничный вывод по курсору: ответ начинается строго после строки, на которой кончилась прошлая страница, в
|
|
124
115
|
* порядке `sort()` (без `sort()` — ошибка `invalid_query`). Курсор этой строки даёт `cursorOf(строка, поле)` с тем же
|
|
125
116
|
* полем, что в `sort()`. Листает `rows()`, `ids()`, `versions()` и `paths()` (курсор пути — `cursorOf(путь, поле)`);
|
|
126
|
-
* `count()
|
|
117
|
+
* перед `count()` и агрегатами (`sum`, `avg`, `min`, `max`, `countBy`) — `invalid_query`: они считают по всем объектам.
|
|
127
118
|
*/
|
|
128
119
|
after(cursor: Cursor): Chain;
|
|
129
120
|
/**
|
|
@@ -161,14 +152,19 @@ export interface ChainCore {
|
|
|
161
152
|
* же цепочка (та же переменная) второй раз в одном пути шагов не добавляет, а возвращает к своему последнему шагу:
|
|
162
153
|
* следующий шаг пойдёт от него.
|
|
163
154
|
*/
|
|
164
|
-
entity(x:
|
|
155
|
+
entity(x: ChainRef | Row): Chain;
|
|
165
156
|
/** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
|
|
166
157
|
create(data?: Record<string, unknown>, opts?: {
|
|
167
158
|
reason?: string;
|
|
168
159
|
}): Chain;
|
|
169
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — с проверкой версии. Цели сужают
|
|
162
|
+
* фильтр, id, `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`; правка всех строк —
|
|
163
|
+
* `{ all: true }`.
|
|
164
|
+
*/
|
|
170
165
|
update(patch?: Record<string, unknown>, opts?: {
|
|
171
166
|
rev?: number;
|
|
167
|
+
all?: boolean;
|
|
172
168
|
reason?: string;
|
|
173
169
|
}): Chain;
|
|
174
170
|
/** Создать или заменить целиком (класс с ключом); строка ответа несёт $upsert. */
|
|
@@ -178,18 +174,13 @@ export interface ChainCore {
|
|
|
178
174
|
/**
|
|
179
175
|
* Удаляет цели шага; строки, которые на них ссылаются, база удаляет вместе с ними, отвязывает или не даёт удалить цель —
|
|
180
176
|
* как задано в `onDelete` их ссылок. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
|
|
181
|
-
* вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет.
|
|
177
|
+
* вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. С `{ confirm: true }` цели сужают
|
|
178
|
+
* фильтр, id, `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`; удаление всех строк —
|
|
179
|
+
* `{ confirm: true, all: true }`.
|
|
182
180
|
*/
|
|
183
181
|
delete(opts?: {
|
|
184
182
|
confirm?: boolean;
|
|
185
|
-
|
|
186
|
-
}): Chain;
|
|
187
|
-
/** Затереть строковые поля ('[erased]') и пометить тегом anonymized. */
|
|
188
|
-
anonymize(fields: string[], opts?: {
|
|
189
|
-
reason?: string;
|
|
190
|
-
}): Chain;
|
|
191
|
-
/** Сменить класс: у класса с ключом — новый id и перевод ссылок. */
|
|
192
|
-
reclass(cls: string, data?: Record<string, unknown>, opts?: {
|
|
183
|
+
all?: boolean;
|
|
193
184
|
reason?: string;
|
|
194
185
|
}): Chain;
|
|
195
186
|
/** Чтение с блокировкой строк последнего шага до конца db.begin() (rows, first, ids). */
|
|
@@ -198,22 +189,6 @@ export interface ChainCore {
|
|
|
198
189
|
restore(opts?: {
|
|
199
190
|
reason?: string;
|
|
200
191
|
}): Chain;
|
|
201
|
-
/**
|
|
202
|
-
* Стирает историю удалённых объектов шага: все их версии в журнале, от каждого остаётся одна запись `purge`. Удалённые
|
|
203
|
-
* объекты шаг находит сам, без `withDeleted()`. Без `{ confirm: true }` — предпросмотр: ничего не стирается, терминал
|
|
204
|
-
* вернёт надгробия с `$versions` — сколько версий сотрётся.
|
|
205
|
-
*/
|
|
206
|
-
purge(opts?: {
|
|
207
|
-
confirm?: boolean;
|
|
208
|
-
reason?: string;
|
|
209
|
-
}): Chain;
|
|
210
|
-
/**
|
|
211
|
-
* Новое значение ключа объектов шага. id вычисляется из ключа, поэтому объект получает новый id: ссылки других строк
|
|
212
|
-
* на него переводятся на новый id, а журнал связывает старый id с новым.
|
|
213
|
-
*/
|
|
214
|
-
rekey(patch: Record<string, unknown>, opts?: {
|
|
215
|
-
reason?: string;
|
|
216
|
-
}): Chain;
|
|
217
192
|
/** Кэш результатов (§2.12): ответ хранится в памяти процесса ttl секунд (по умолчанию 60). */
|
|
218
193
|
cache(opts?: {
|
|
219
194
|
ttl?: number;
|
|
@@ -234,6 +209,8 @@ export type StepFn = ((filter?: Filter) => Chain) & {
|
|
|
234
209
|
export type Chain = ChainCore & {
|
|
235
210
|
[className: string]: StepFn;
|
|
236
211
|
};
|
|
212
|
+
/** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение слота. */
|
|
213
|
+
export type ChainRef = Pick<ChainCore, 'ids'>;
|
|
237
214
|
/** Внутренний канал: план чужой ленивой цепочки (entity()). */
|
|
238
215
|
export declare const PLAN: unique symbol;
|
|
239
216
|
/** entity-идентичность: переменная-цепочка → nodeKey её узла в этом пути. */
|
|
@@ -253,5 +230,5 @@ export declare function makeBatch(ctx: ChainCtx, queue: BatchEntry[]): Batch;
|
|
|
253
230
|
/** Старт пути от корня db: класс, алиас или роль. */
|
|
254
231
|
export declare function startStep(ctx: ChainCtx, name: string): StepFn;
|
|
255
232
|
/** Старт пути с готового узла: db.entity(row | цепочка). */
|
|
256
|
-
export declare function startEntity(ctx: ChainCtx, x:
|
|
233
|
+
export declare function startEntity(ctx: ChainCtx, x: ChainRef | Row): Chain;
|
|
257
234
|
export {};
|