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.
Files changed (112) hide show
  1. package/AGENT-CHEATSHEET.md +329 -281
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +572 -1053
  4. package/dist/acl.d.ts +78 -1
  5. package/dist/acl.js +264 -13
  6. package/dist/admin.d.ts +10 -141
  7. package/dist/admin.js +24 -184
  8. package/dist/auth.d.ts +2 -2
  9. package/dist/auth.js +5 -15
  10. package/dist/cache.d.ts +8 -1
  11. package/dist/cache.js +30 -8
  12. package/dist/chain.d.ts +18 -41
  13. package/dist/chain.js +165 -203
  14. package/dist/cli.js +45 -90
  15. package/dist/demo/booking.d.ts +352 -36
  16. package/dist/describe.d.ts +47 -0
  17. package/dist/describe.js +195 -0
  18. package/dist/errors.d.ts +59 -13
  19. package/dist/errors.js +190 -29
  20. package/dist/index.d.ts +52 -70
  21. package/dist/index.js +189 -122
  22. package/dist/internal.d.ts +5 -0
  23. package/dist/jsonschema.js +18 -17
  24. package/dist/load.d.ts +20 -12
  25. package/dist/load.js +207 -181
  26. package/dist/mcp.d.ts +5 -7
  27. package/dist/mcp.js +168 -214
  28. package/dist/messages/access.d.ts +90 -0
  29. package/dist/messages/access.js +113 -0
  30. package/dist/messages/api.d.ts +171 -0
  31. package/dist/messages/api.js +215 -0
  32. package/dist/messages/boot.d.ts +201 -0
  33. package/dist/messages/boot.js +256 -0
  34. package/dist/messages/codes.d.ts +172 -0
  35. package/dist/messages/codes.js +173 -0
  36. package/dist/messages/index.d.ts +95 -0
  37. package/dist/messages/index.js +118 -0
  38. package/dist/messages/mcp.d.ts +306 -0
  39. package/dist/messages/mcp.js +387 -0
  40. package/dist/messages/model.d.ts +233 -0
  41. package/dist/messages/model.js +283 -0
  42. package/dist/messages/query.d.ts +131 -0
  43. package/dist/messages/query.js +158 -0
  44. package/dist/messages/sql-access.d.ts +70 -0
  45. package/dist/messages/sql-access.js +87 -0
  46. package/dist/messages/sql-core.d.ts +150 -0
  47. package/dist/messages/sql-core.js +181 -0
  48. package/dist/messages/sql-time.d.ts +71 -0
  49. package/dist/messages/sql-time.js +88 -0
  50. package/dist/messages/sql-write.d.ts +246 -0
  51. package/dist/messages/sql-write.js +270 -0
  52. package/dist/messages/time.d.ts +23 -0
  53. package/dist/messages/time.js +29 -0
  54. package/dist/messages/types.d.ts +27 -0
  55. package/dist/messages/types.js +5 -0
  56. package/dist/messages/write.d.ts +405 -0
  57. package/dist/messages/write.js +509 -0
  58. package/dist/model.d.ts +73 -60
  59. package/dist/model.js +20 -29
  60. package/dist/ops.d.ts +35 -0
  61. package/dist/ops.js +101 -0
  62. package/dist/pglite.d.ts +0 -9
  63. package/dist/pglite.js +3 -1
  64. package/dist/registry.d.ts +3 -19
  65. package/dist/registry.js +3 -16
  66. package/dist/signals.d.ts +1 -5
  67. package/dist/signals.js +1 -5
  68. package/dist/sql.d.ts +7 -1
  69. package/dist/sql.js +101 -85
  70. package/dist/sync.d.ts +9 -3
  71. package/dist/sync.js +40 -17
  72. package/dist/tx.d.ts +11 -1
  73. package/dist/tx.js +32 -11
  74. package/dist/typed.d.ts +240 -45
  75. package/dist/types.d.ts +15 -90
  76. package/dist/types.js +12 -14
  77. package/dist/up.d.ts +53 -32
  78. package/dist/up.js +180 -74
  79. package/dist/uuid.js +5 -5
  80. package/dist/validate.d.ts +13 -1
  81. package/dist/validate.js +61 -29
  82. package/dist/watch.d.ts +5 -16
  83. package/dist/watch.js +23 -77
  84. package/dist/write.d.ts +9 -16
  85. package/dist/write.js +102 -185
  86. package/llms.txt +22 -25
  87. package/package.json +2 -9
  88. package/sql/10-core.sql +7 -50
  89. package/sql/15-errors.sql +64 -10
  90. package/sql/20-context.sql +7 -6
  91. package/sql/30-validate.sql +35 -27
  92. package/sql/40-class.sql +20 -114
  93. package/sql/50-acl.sql +8 -13
  94. package/sql/60-write.sql +220 -676
  95. package/sql/70-read.sql +11 -13
  96. package/sql/80-auth.sql +23 -27
  97. package/sql/90-time.sql +77 -944
  98. package/sql/95-seed.system.sql +7 -14
  99. package/sql/99-revision.sql +2 -1
  100. package/sql/README.md +32 -24
  101. package/sql/migrate/README.md +14 -0
  102. package/AGENT-CHEATSHEET.en.md +0 -488
  103. package/MIGRATION.md +0 -190
  104. package/README.en.md +0 -2910
  105. package/dist/import.d.ts +0 -185
  106. package/dist/import.js +0 -820
  107. package/dist/project.d.ts +0 -359
  108. package/dist/project.js +0 -1258
  109. package/sql/75-history.sql +0 -436
  110. package/sql/88-derived.sql +0 -1236
  111. package/sql/migrate/0006-changed-paths.sql +0 -3
  112. package/sql/migrate/0007-secret-account-fk.sql +0 -12
package/dist/admin.js CHANGED
@@ -1,60 +1,25 @@
1
- import { LetopisError, fromDbError } from './errors.js';
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
- * первым делом берёт строку-замок обслуживания (lock_try в maintain() базы: for update skip locked строки
16
- * secret, которую посторонняя роль не займёт), и та снимается с фиксацией пачки. Сеансовая блокировка
17
- * за пулом в режиме транзакций (PgBouncer) оставалась бы на
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, opts = {}) {
15
+ export async function maintain(a) {
23
16
  if (a.exec.explicit)
24
- throw new LetopisError('invalid_query', 'maintain() — пачками в своих транзакциях, не внутри db.begin()');
25
- const now = opts.now === undefined ? null : opts.now instanceof Date ? opts.now.toISOString() : opts.now;
26
- const report = { skipped: false, sessions: 0, codes: 0, thinned: 0, batches: 0, more: false, queue: 0 };
27
- const max = opts.batches ?? 100;
28
- for (let i = 0; i < max; i++) {
29
- // запуск по расписанию остановлен (close()): следующая пачка не начинается, отчёт — с more: true
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: очередь сигналов PostgreSQL заполнена на ${(queue * 100).toFixed(0)} % — проверьте зависших слушателей` : null;
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 new LetopisError('invalid_data', `интервал ${shown}: число мс или строка '30s', '15m', '1h', '1d' — от 1 мс до 2³¹−1 мс (около 24 суток)`);
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
- * начинать новых пачек и ждёт его конца — close() закрывает пул после неё, а не посреди пачки.
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(() => stopped)
101
- .then(() => { }, (e) => console.warn(`letopis: maintain по расписанию: ${e.message}`))
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$соль$хэш` (формат 0.21). */
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
- * Удалить аккаунт (этап 6; accounts.purge 0.21): системный администратор или владелец его
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 { LetopisError, removed } from './errors.js';
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$соль$хэш` (формат 0.21). */
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 new LetopisError('invalid_data', `хэш пароля: параметры scrypt N=${N}, r=${r}, p=${p} недопустимы или требуют больше 64 МБ памяти`);
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 new LetopisError('invalid_data', 'секрет TOTP — не base32');
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 new LetopisError('invalid_query', 'auth.switch() — от корня подключения: при имперсонации арендатора задаёт db.as(…, { tenant })');
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
- * каскада, unset, смены класса и ключа); запись через db.sql — весь кэш;
8
+ * каскада и unset); запись через db.sql — весь кэш;
9
9
  * - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
10
10
  * сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
11
- * opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) и «*» —
12
- * весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
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: this.sessionEpoch.get(session) ?? 0,
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.bump(this.sessionEpoch, tag);
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
- // *, session:*, opaque без ключа, нагрузка не по режиму
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 { BlameOptions, BlameResult, ChainMods, Cursor, DiffOptions, DiffResult, Filter, Path, QueryEvent, Row } from './types.js';
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
- * Все версии сущностей последнего шага (надгробия — $deleted), по id и номеру версии;
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()`, агрегаты, `diff()` и `blame()` курсор не учитывают.
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: Chain | Row): Chain;
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
- /** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — с проверкой версии. */
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
- reason?: string;
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: Chain | Row): Chain;
233
+ export declare function startEntity(ctx: ChainCtx, x: ChainRef | Row): Chain;
257
234
  export {};