letopis 2.0.0 → 3.0.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.
Files changed (83) hide show
  1. package/AGENT-CHEATSHEET.md +491 -414
  2. package/CHANGELOG.md +361 -0
  3. package/LICENSE +75 -21
  4. package/LICENSE-COMMERCIAL.md +49 -0
  5. package/README.md +969 -210
  6. package/api-contract.json +385 -0
  7. package/dist/acl.d.ts +7 -2
  8. package/dist/acl.js +19 -2
  9. package/dist/admin.d.ts +7 -1
  10. package/dist/admin.js +16 -9
  11. package/dist/auth.d.ts +47 -11
  12. package/dist/auth.js +129 -15
  13. package/dist/chain.d.ts +52 -25
  14. package/dist/chain.js +445 -64
  15. package/dist/cli.js +72 -20
  16. package/dist/errors.d.ts +23 -6
  17. package/dist/errors.js +52 -13
  18. package/dist/index.d.ts +32 -15
  19. package/dist/index.js +209 -68
  20. package/dist/internal.d.ts +2 -0
  21. package/dist/load.d.ts +10 -6
  22. package/dist/load.js +30 -11
  23. package/dist/log.d.ts +121 -0
  24. package/dist/log.js +654 -0
  25. package/dist/mcp.d.ts +1 -1
  26. package/dist/mcp.js +34 -16
  27. package/dist/messages/access.d.ts +10 -0
  28. package/dist/messages/access.js +13 -0
  29. package/dist/messages/api.d.ts +76 -0
  30. package/dist/messages/api.js +109 -7
  31. package/dist/messages/boot.d.ts +5 -0
  32. package/dist/messages/boot.js +11 -4
  33. package/dist/messages/codes.js +4 -4
  34. package/dist/messages/index.d.ts +5 -2
  35. package/dist/messages/index.js +5 -2
  36. package/dist/messages/log.d.ts +232 -0
  37. package/dist/messages/log.js +105 -0
  38. package/dist/messages/mcp.js +7 -7
  39. package/dist/messages/model.d.ts +40 -1
  40. package/dist/messages/model.js +52 -3
  41. package/dist/messages/query.d.ts +10 -0
  42. package/dist/messages/query.js +13 -0
  43. package/dist/messages/sql-core.d.ts +15 -1
  44. package/dist/messages/sql-core.js +20 -3
  45. package/dist/messages/sql-time.d.ts +15 -0
  46. package/dist/messages/sql-time.js +23 -0
  47. package/dist/messages/write.d.ts +52 -0
  48. package/dist/messages/write.js +77 -6
  49. package/dist/model.d.ts +10 -2
  50. package/dist/model.js +28 -6
  51. package/dist/registry.d.ts +6 -0
  52. package/dist/registry.js +6 -0
  53. package/dist/sql.d.ts +2 -0
  54. package/dist/sql.js +77 -25
  55. package/dist/sync.d.ts +4 -5
  56. package/dist/sync.js +53 -4
  57. package/dist/tx.d.ts +10 -1
  58. package/dist/tx.js +44 -2
  59. package/dist/typed.d.ts +18 -14
  60. package/dist/types.d.ts +91 -8
  61. package/dist/types.js +8 -3
  62. package/dist/up.d.ts +8 -5
  63. package/dist/up.js +71 -23
  64. package/dist/uuid.d.ts +8 -0
  65. package/dist/uuid.js +9 -1
  66. package/dist/validate.js +24 -7
  67. package/dist/watch.d.ts +5 -0
  68. package/dist/watch.js +13 -0
  69. package/dist/write.d.ts +84 -3
  70. package/dist/write.js +427 -40
  71. package/llms.txt +38 -17
  72. package/package.json +12 -6
  73. package/sql/20-context.sql +11 -3
  74. package/sql/30-validate.sql +51 -10
  75. package/sql/40-class.sql +21 -1
  76. package/sql/60-write.sql +18 -6
  77. package/sql/80-auth.sql +88 -17
  78. package/sql/90-time.sql +34 -6
  79. package/sql/99-revision.sql +5 -1
  80. package/sql/migrate/0009-restore-rev.tenants-name.sql +10 -0
  81. package/sql/migrate/0010-impersonation-no-account.sql +5 -0
  82. package/sql/migrate/0011-key-lock.otp-enabled.sql +7 -0
  83. package/sql/migrate/README.md +5 -1
package/dist/auth.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
13
13
  import { promisify } from 'node:util';
14
14
  import { lerr } from './errors.js';
15
+ import { shortIntervalMs } from './admin.js';
15
16
  const scryptAsync = promisify(scrypt);
16
17
  const SCRYPT = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
17
18
  const KEYLEN = 32;
@@ -83,7 +84,46 @@ export function totpCode(secretBase32, atMs = Date.now()) {
83
84
  return String(code).padStart(TOTP_DIGITS, '0');
84
85
  }
85
86
  const accId = (a) => (typeof a === 'object' ? a.id : a);
86
- const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
87
+ /** Логин без крайних пробелов (план 2.1, A12): « anna » и «anna» — один логин; не строка — как есть (отказ даст база). */
88
+ const trimLogin = (v) => (typeof v === 'string' ? v.trim() : v);
89
+ /**
90
+ * Срок ttl (3.0.1) — интервал для базы: число секунд, короткая форма как у connect({ maintain }) ('30s', '15m', '2h',
91
+ * '1d') или строка-интервал PostgreSQL ('15 minutes') — её разбирает база, ошибку разбора переводит ttlGuard. Не задан —
92
+ * null (срок по умолчанию). Не число больше нуля, пустая строка, другой тип — invalid_data (access.bad_ttl).
93
+ */
94
+ export function ttlOf(v, where) {
95
+ if (v === undefined || v === null)
96
+ return null;
97
+ if (typeof v === 'number') {
98
+ if (Number.isFinite(v) && v > 0)
99
+ return `${Math.max(1, Math.round(v))} seconds`;
100
+ }
101
+ else if (typeof v === 'string' && v.trim() !== '') {
102
+ const ms = shortIntervalMs(v);
103
+ if (ms === null)
104
+ return v.trim();
105
+ if (ms > 0)
106
+ return `${ms / 1000} seconds`;
107
+ }
108
+ throw lerr('access.bad_ttl', { where, value: shownTtl(v) });
109
+ }
110
+ const shownTtl = (v) => (typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v);
111
+ /**
112
+ * Строка ttl, которую не принял разбор интервала PostgreSQL (22007 — не интервал, 22008 и 22015 — вне диапазона), —
113
+ * invalid_data access.bad_ttl вместо сырой ошибки базы; прочие ошибки — как есть.
114
+ */
115
+ export async function ttlGuard(run, ttl, where) {
116
+ try {
117
+ return await run;
118
+ }
119
+ catch (e) {
120
+ const code = e?.code;
121
+ if (typeof ttl === 'string' && (code === '22007' || code === '22008' || code === '22015')) {
122
+ throw lerr('access.bad_ttl', { where, value: shownTtl(ttl) }, { cause: e });
123
+ }
124
+ throw e;
125
+ }
126
+ }
87
127
  /** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
88
128
  export function makeAuth(S, exec, onSwitch, canSwitch) {
89
129
  const one = async (text, params = []) => {
@@ -93,7 +133,8 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
93
133
  // Вход после первой проверки. Код TOTP проверяет база той же функцией, что verifyTotp (право auth.totp,
94
134
  // окно ±1 шаг, повтор шага, счётчик неудач и блокировка), секрет из базы не выходит; способ TOTP
95
135
  // сессия получает только после удачи. Без кода — сессия без отметки TOTP, как раньше.
96
- const login = async (account, ttl, methods, totp) => {
136
+ const login = async (account, ttl, methods, totp, where = 'auth') => {
137
+ const interval = ttlOf(ttl, where);
97
138
  if (!account)
98
139
  return null;
99
140
  if (totp != null) {
@@ -102,28 +143,31 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
102
143
  return null;
103
144
  methods = [...methods, 'TOTP'];
104
145
  }
105
- const r = await one(`select ${S}.issue_session($1::uuid, coalesce($2::interval, interval '7 days'), $3::text[]) as t`, [account, ttlOf(ttl), methods]);
146
+ const r = await ttlGuard(one(`select ${S}.issue_session($1::uuid, coalesce($2::interval, interval '7 days'), $3::text[]) as t`, [account, interval, methods]), ttl, where);
106
147
  return { account, token: r.t };
107
148
  };
108
149
  return {
109
150
  async setPassword(a) {
110
151
  const hash = await hashPassword(a.password);
111
- const r = await one(`select ${S}.auth_set_password($1::uuid, $2, $3, $4, $5, $6) as id`, [accId(a.account), a.identifier, hash, a.kind ?? 'PASSWORD', a.confirmed ?? null, a.keepCurrent ?? true]);
152
+ const r = await one(`select ${S}.auth_set_password($1::uuid, $2, $3, $4, $5, $6) as id`, [accId(a.account), trimLogin(a.identifier), hash, a.kind ?? 'PASSWORD', a.confirmed ?? null, a.keepCurrent ?? true]);
112
153
  return r.id;
113
154
  },
114
155
  async verifyPassword(a) {
115
- const rows = await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [a.identifier, a.kind ?? 'PASSWORD']));
116
- const cred = rows[0];
156
+ ttlOf(a.ttl, 'verifyPassword');
157
+ const get = async (ident) => (await exec.run((q) => q.unsafe(`select account, hash, confirmed from ${S}.auth_password_get($1, $2)`, [ident, a.kind ?? 'PASSWORD'])))[0];
158
+ // логин без крайних пробелов (план 2.1, A12); кред, записанный до 2.1 с пробелами, находится по логину как есть
159
+ const ident = trimLogin(a.identifier);
160
+ const cred = (await get(ident)) ?? (ident !== a.identifier ? await get(a.identifier) : undefined);
117
161
  if (!cred)
118
162
  return dummyVerify(a.password).then(() => null);
119
163
  if (!(await verifyHash(a.password, cred.hash)))
120
164
  return null;
121
165
  if (!cred.confirmed && a.requireConfirmed !== false)
122
166
  return null;
123
- return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp);
167
+ return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp, 'verifyPassword');
124
168
  },
125
169
  async issueApiKey(a) {
126
- return one(`select key, id from ${S}.auth_issue_apikey($1::uuid, $2, $3::interval)`, [accId(a.account), a.name ?? null, ttlOf(a.ttl)]);
170
+ return ttlGuard(one(`select key, id from ${S}.auth_issue_apikey($1::uuid, $2, $3::interval)`, [accId(a.account), a.name ?? null, ttlOf(a.ttl, 'issueApiKey')]), a.ttl, 'issueApiKey');
127
171
  },
128
172
  async verifyApiKey(key) {
129
173
  return (await one(`select ${S}.auth_login_apikey($1) as r`, [key])).r;
@@ -148,7 +192,11 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
148
192
  return (await one(`select ${S}.auth_totp_enabled($1::uuid) as ok`, [accId(account)])).ok;
149
193
  },
150
194
  async issueOtp(a) {
151
- const r = await one(`select ${S}.auth_issue_otp($1::uuid, $2, $3, coalesce($4::interval, interval '10 minutes'), $5, $6) as c`, [accId(a.account), a.identifier, a.kind ?? 'OTP', ttlOf(a.ttl), a.digits ?? 6, a.maxAttempts ?? 5]);
195
+ // 3.0.1: число попыток вне 1–10 — отказ, а не молчаливое приведение к пределу в базе
196
+ const max = a.maxAttempts;
197
+ if (max !== undefined && !(Number.isInteger(max) && max >= 1 && max <= 10))
198
+ throw lerr('access.bad_max_attempts', { value: shownTtl(max) });
199
+ const r = await ttlGuard(one(`select ${S}.auth_issue_otp($1::uuid, $2, $3, coalesce($4::interval, interval '10 minutes'), $5, $6) as c`, [accId(a.account), a.identifier, a.kind ?? 'OTP', ttlOf(a.ttl, 'issueOtp'), a.digits ?? 6, max ?? 5]), a.ttl, 'issueOtp');
152
200
  return { code: r.c };
153
201
  },
154
202
  async verifyOtp(a) {
@@ -158,8 +206,9 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
158
206
  return (await one(`select ${S}.auth_link($1::uuid, $2, $3, $4::jsonb, $5) as id`, [accId(a.account), a.kind, a.identifier, (a.meta ?? {}), a.confirmed ?? false])).id;
159
207
  },
160
208
  async lookup(a) {
209
+ ttlOf(a.ttl, 'lookup');
161
210
  const r = await one(`select ${S}.auth_lookup($1, $2, $3) as a`, [a.kind, a.identifier, a.requireConfirmed ?? true]);
162
- return login(r.a, a.ttl, [a.kind], a.totp);
211
+ return login(r.a, a.ttl, [a.kind], a.totp, 'lookup');
163
212
  },
164
213
  async credentials(account) {
165
214
  return exec.run((q) => q.unsafe(`select * from ${S}.auth_credentials($1::uuid)`, [accId(account)]));
@@ -171,7 +220,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
171
220
  return (await one(`select ${S}.auth_set_flags($1::uuid, $2, $3) as ok`, [id, flags.confirmed ?? null, flags.enabled ?? null])).ok;
172
221
  },
173
222
  async sessionFor(account, opts) {
174
- return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
223
+ return (await login(accId(account), opts?.ttl, ['SERVICE'], undefined, 'sessionFor')).token;
175
224
  },
176
225
  async session(token) {
177
226
  const r = (await one(`select ${S}.auth_session_info($1) as r`, [token])).r;
@@ -179,9 +228,12 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
179
228
  return r && { account: r.account, tenant: r.tenant, member: r.member, expiresAt: r.expiresAt, loginAt: r.loginAt, methods: r.methods };
180
229
  },
181
230
  async refresh(opts) {
182
- return (await one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl)])).e;
231
+ return (await ttlGuard(one(`select ${S}.auth_refresh(coalesce($1::interval, interval '7 days')) as e`, [ttlOf(opts?.ttl, 'refresh')]), opts?.ttl, 'refresh')).e;
183
232
  },
184
- async revoke() {
233
+ async revoke(token) {
234
+ // с токеном — выход пользователя по токену (план 2.1, A3): право auth.session, как у session(token)
235
+ if (token !== undefined)
236
+ return (await one(`select ${S}.auth_logout_token($1) as ok`, [token])).ok;
185
237
  return (await one(`select ${S}.auth_logout() as ok`)).ok;
186
238
  },
187
239
  async revokeAll(account) {
@@ -194,8 +246,9 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
194
246
  await onSwitch();
195
247
  },
196
248
  async tenants() {
197
- const rows = await exec.run((q) => q.unsafe(`select tenant, roles from ${S}.auth_tenants()`));
198
- return rows.map((r) => ({ tenant: r.tenant, roles: r.roles }));
249
+ // name и personal — с ревизии 9 (план 2.1, A5): personal — собственный арендатор аккаунта сессии (tenant = account)
250
+ const rows = await exec.run((q) => q.unsafe(`select tenant, roles, name, personal from ${S}.auth_tenants()`));
251
+ return rows.map((r) => ({ tenant: r.tenant, roles: r.roles, name: r.name ?? null, personal: r.personal === true }));
199
252
  },
200
253
  async purgeAccount(account) {
201
254
  const r = (await one(`select ${S}.account_purge($1::uuid) as r`, [accId(account)])).r;
@@ -206,3 +259,64 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
206
259
  },
207
260
  };
208
261
  }
262
+ const argOf = (args, i = 0) => (args[i] !== null && typeof args[i] === 'object' ? args[i] : {});
263
+ /** Аккаунт аргумента: id или { id }. */
264
+ const accOf = (a) => (typeof a === 'string' ? a : a !== null && typeof a === 'object' && typeof a.id === 'string' ? a.id : undefined);
265
+ const loginOf = (v) => (typeof v === 'string' ? { login: v.trim() } : {});
266
+ /** Вход: успех — auth.login (account — вошедший), отказ — auth.fail. */
267
+ function loginEvent(r, method, extra = {}) {
268
+ const res = r;
269
+ const detail = { method, ...extra };
270
+ return res && typeof res === 'object' ? { event: 'auth.login', account: res.account, detail } : { event: 'auth.fail', detail };
271
+ }
272
+ const credential = (action, detail) => ({ event: 'auth.credential', detail: { action, ...detail } });
273
+ const call = (method, detail = {}) => ({ event: 'auth.call', detail: { method, ...detail } });
274
+ /** Что пишет в лог каждый метод db.auth. */
275
+ export const AUTH_LOG = {
276
+ setPassword: (args, id) => {
277
+ const a = argOf(args);
278
+ return credential('setPassword', { account: accOf(a.account), kind: a.kind ?? 'PASSWORD', ...loginOf(a.identifier), id });
279
+ },
280
+ verifyPassword: (args, r) => {
281
+ const a = argOf(args);
282
+ return loginEvent(r, String(a.kind ?? 'PASSWORD'), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
283
+ },
284
+ issueApiKey: (args, r) => credential('issueApiKey', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
285
+ verifyApiKey: (_args, r) => loginEvent(r, 'APIKEY'),
286
+ issueKeySecret: (args, r) => credential('issueKeySecret', { account: accOf(argOf(args).account), name: argOf(args).name, id: r?.id }),
287
+ verifyKeySecret: (_args, r) => loginEvent(r, 'KEYSECRET'),
288
+ enrollTotp: (args) => credential('enrollTotp', { account: accOf(argOf(args).account) }),
289
+ verifyTotp: (args, ok) => (ok === true
290
+ ? call('verifyTotp', { account: accOf(argOf(args).account), ok: true })
291
+ : { event: 'auth.fail', detail: { method: 'TOTP', account: accOf(argOf(args).account) } }),
292
+ totpEnabled: (args, r) => call('totpEnabled', { account: accOf(args[0]), enabled: r }),
293
+ issueOtp: (args) => {
294
+ const a = argOf(args);
295
+ return credential('issueOtp', { account: accOf(a.account), kind: a.kind ?? 'OTP', ...loginOf(a.identifier) });
296
+ },
297
+ verifyOtp: (args, r) => {
298
+ const a = argOf(args);
299
+ return loginEvent(r, String(a.kind ?? 'OTP'), loginOf(a.identifier));
300
+ },
301
+ link: (args, id) => {
302
+ const a = argOf(args);
303
+ return credential('link', { account: accOf(a.account), kind: a.kind, ...loginOf(a.identifier), id });
304
+ },
305
+ lookup: (args, r) => {
306
+ const a = argOf(args);
307
+ return loginEvent(r, String(a.kind), { ...loginOf(a.identifier), ...(a.totp != null ? { totp: true } : {}) });
308
+ },
309
+ credentials: (args, r) => ({ ...call('credentials', { account: accOf(args[0]) }), rows: Array.isArray(r) ? r.length : undefined }),
310
+ revokeCredential: (args, ok) => ({ event: 'auth.revoke', detail: { target: 'credential', id: args[0], ok } }),
311
+ setFlags: (args, ok) => credential('setFlags', { id: args[0], ...argOf(args, 1), ok }),
312
+ sessionFor: (args) => ({ event: 'auth.session', detail: { account: accOf(args[0]), method: 'SERVICE' } }),
313
+ session: (_args, r) => call('session', { found: r !== null }),
314
+ refresh: () => call('refresh'),
315
+ revoke: (args, ok) => (args[0] !== undefined
316
+ ? { event: 'auth.revoke', detail: { target: 'token', ok } }
317
+ : { event: 'auth.logout', detail: { ok } }),
318
+ revokeAll: (args, n) => ({ event: 'auth.revoke', detail: { target: 'all', account: accOf(args[0]), n } }),
319
+ switch: (args, _r, before) => ({ event: 'auth.switch', tenant: String(args[0]), detail: { from: before } }),
320
+ tenants: (_args, r) => ({ ...call('tenants'), rows: Array.isArray(r) ? r.length : undefined }),
321
+ purgeAccount: (args, r) => ({ event: 'account.purge', detail: { account: accOf(args[0]), ...r } }),
322
+ };
package/dist/chain.d.ts CHANGED
@@ -2,7 +2,9 @@ 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 { ChainMods, Cursor, Filter, Path, QueryEvent, Row } from './types.js';
5
+ import { type ChainMods, type Cursor, type Filter, type Path, type QueryEvent, type Row } from './types.js';
6
+ import { type Log } from './log.js';
7
+ import { type DeleteOptions, type RestoreOptions } from './write.js';
6
8
  export interface ChainCtx {
7
9
  readonly reg: Registry;
8
10
  readonly schema: string;
@@ -10,6 +12,10 @@ export interface ChainCtx {
10
12
  readonly exec: Executor;
11
13
  /** Арендатор сессии. */
12
14
  readonly tenant: () => string | null;
15
+ /** Аккаунт сессии (с имперсонацией — тот, от чьего имени): арендатор равен ему — личный арендатор. */
16
+ readonly account?: () => string | null;
17
+ /** Фасад входит по токену сессии (connect({ token }), ключ API): без арендатора сессии шаг — sessionless(). */
18
+ readonly hasToken?: () => boolean;
13
19
  /** Владелец новых строк по умолчанию (db.as(…, { owner })). */
14
20
  readonly owner?: string;
15
21
  readonly onQuery?: (e: QueryEvent) => void;
@@ -28,6 +34,8 @@ export interface ChainCtx {
28
34
  tenant?: string | null;
29
35
  };
30
36
  };
37
+ /** Логгер фасада (lib/src/log.ts): события read, write, delete, restore, кэша и error. */
38
+ readonly log?: Log;
31
39
  }
32
40
  /** План в очереди батча; steps обновляется по мере роста цепочки. */
33
41
  export interface BatchEntry {
@@ -53,7 +61,7 @@ export type Batch = {
53
61
  };
54
62
  export interface ChainCore {
55
63
  /**
56
- * Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные ссылками; приходит объектом, где ключ — имя
64
+ * Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные концами; приходит объектом, где ключ — имя
57
65
  * шага (или имя из `alias()`), а значение — строка этого шага: `{ Shop: …, Product: … }`. Если к объекту последнего
58
66
  * шага ведут несколько путей, придут все.
59
67
  */
@@ -65,11 +73,14 @@ export interface ChainCore {
65
73
  rows(): Promise<Row[]>;
66
74
  first(): Promise<Row | null>;
67
75
  ids(): Promise<string[]>;
68
- /** Число сущностей последнего шага; { paths: true } — число путей. */
76
+ /** Число объектов последнего шага; { paths: true } — число путей. */
69
77
  count(opts?: {
70
78
  paths?: boolean;
71
79
  }): Promise<number>;
72
- /** Все версии сущностей последнего шага (надгробия — $deleted), по id и номеру версии. */
80
+ /**
81
+ * Все версии объектов последнего шага (надгробия — $deleted), по id и номеру версии. Шаг с явными id находит и
82
+ * удалённые объекты: `db.Класс(id).versions()` отдаёт историю удалённого.
83
+ */
73
84
  versions(): Promise<Row[]>;
74
85
  /**
75
86
  * Сумма поля `'data.<путь>'` по объектам последнего шага: значения приводятся к числу, складывает база. `limit()` и
@@ -101,10 +112,14 @@ export interface ChainCore {
101
112
  /**
102
113
  * Не больше n строк ответа: объектов последнего шага, у `paths()` — путей, у `versions()` — версий, перед `countBy()` —
103
114
  * групп. До глагола сужает его цели. Сразу после глагола перед `rows`, `first`, `ids`, `count` и `paths` —
104
- * `invalid_query`: глагол уже прошёл по всем целям, сужать нечего.
115
+ * `invalid_query`: глагол уже прошёл по всем целям, сужать нечего. `n` — целое от 0: дробное — `invalid_query`,
116
+ * отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201W).
105
117
  */
106
118
  limit(n: number): Chain;
107
- /** Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. */
119
+ /**
120
+ * Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. `n` — целое от 0:
121
+ * дробное — `invalid_query`, отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201X).
122
+ */
108
123
  offset(n: number): Chain;
109
124
  /** Сортировка по полю последнего шага: 'at' | 'rev' | 'id' | 'data.<путь>'. */
110
125
  sort(field: string, dir?: 'asc' | 'desc' | boolean): Chain;
@@ -118,8 +133,9 @@ export interface ChainCore {
118
133
  */
119
134
  after(cursor: Cursor): Chain;
120
135
  /**
121
- * Последний шаг находит и удалённые объекты. Удалённый объект приходит надгробием — своей последней версией, версией
122
- * удаления, — с `$deleted: true`.
136
+ * Шаг, после которого стоит, находит и удалённые объекты — модификатор своего шага, как `exact()`:
137
+ * `db.Member(id).withDeleted().Loan().withDeleted()` — удалённые выдачи удалённого участника. Удалённый объект приходит
138
+ * надгробием — своей последней версией, версией удаления, — с `$deleted: true`.
123
139
  */
124
140
  withDeleted(): Chain;
125
141
  /** Дети любой глубины на переходе к тому же классу; $depth в строке. */
@@ -153,7 +169,7 @@ export interface ChainCore {
153
169
  * следующий шаг пойдёт от него.
154
170
  */
155
171
  entity(x: ChainRef | Row): Chain;
156
- /** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
172
+ /** Создать объект: концы — из пути и шагов роли; занятый ключ — ошибка exists с id. */
157
173
  create(data?: Record<string, unknown>, opts?: {
158
174
  reason?: string;
159
175
  }): Chain;
@@ -173,22 +189,28 @@ export interface ChainCore {
173
189
  }): Chain;
174
190
  /**
175
191
  * Удаляет цели шага; строки, которые на них ссылаются, база удаляет вместе с ними, отвязывает или не даёт удалить цель —
176
- * как задано в `onDelete` их ссылок. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
177
- * вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. С `{ confirm: true }` цели сужают
178
- * фильтр, id, `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`; удаление всех строк —
179
- * `{ confirm: true, all: true }`.
192
+ * как задано в `onDelete` их концов. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
193
+ * вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. `{ confirm: true, expect: предпросмотр }`
194
+ * удаляет ровно показанное: в транзакции удаления предпросмотр строится заново и сравнивается с `expect` по `id`, `rev` и
195
+ * `$action`; расхождение — `conflict` (ничего не удалено, новый предпросмотр — `err.detail.preview`); цели сужают фильтр, id,
196
+ * `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`. `{ confirm: true }` без `expect` —
197
+ * `invalid_query` (`write.confirm_without_expect`); осознанно удалить текущий состав целей без сверки (и без фильтра —
198
+ * все строки) — `{ confirm: true, all: true }`.
180
199
  */
181
- delete(opts?: {
182
- confirm?: boolean;
183
- all?: boolean;
184
- reason?: string;
185
- }): Chain;
200
+ delete(opts?: DeleteOptions): Chain;
186
201
  /** Чтение с блокировкой строк последнего шага до конца db.begin() (rows, first, ids). */
187
202
  forUpdate(): Chain;
188
- /** Восстановить удалённые объекты шага: последний снимок новой версией (id прежний). */
189
- restore(opts?: {
190
- reason?: string;
191
- }): Chain;
203
+ /**
204
+ * Восстановить удалённые объекты шага: последний снимок новой версией (id прежний). `{ expect }` — надгробия `{ id, rev }`
205
+ * из ответа `delete()` или `withDeleted()`: восстанавливаются только цели из `expect`, и только если последняя версия
206
+ * каждой — та же; иначе ничего не восстановлено: уже восстановлена — `not_deleted` (`write.restore_already`), id занял
207
+ * новый объект — `not_deleted` (`write.restore_id_taken`), прочее (удалена снова, менялась) — `conflict`
208
+ * (`write.restore_stale`); причины по целям — `err.detail.stale`. `{ expect, partial: true }` — восстановить то, что
209
+ * можно: остальные цели приходят строками `{ …строка, $action: 'skip', $why }`. Без `expect` — `invalid_query`
210
+ * (`write.restore_without_expect`), и для явных id; осознанно вернуть все удалённые цели шага без сверки —
211
+ * `{ all: true }`.
212
+ */
213
+ restore(opts: RestoreOptions): Chain;
192
214
  /** Кэш результатов (§2.12): ответ хранится в памяти процесса ttl секунд (по умолчанию 60). */
193
215
  cache(opts?: {
194
216
  ttl?: number;
@@ -196,8 +218,8 @@ export interface ChainCore {
196
218
  }
197
219
  /**
198
220
  * Шаг цепочки — имя класса, его алиаса или роли с необязательным фильтром: `db.Shop(id).Product({ price: gt(1000) })`;
199
- * каждый следующий шаг переходит по ссылкам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
200
- * роли без вызова задаёт ссылку записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественной ссылки ещё
221
+ * каждый следующий шаг переходит по концам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
222
+ * роли без вызова задаёт конец записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественного конца ещё
201
223
  * `.Роль.add(…)` и `.Роль.remove(…)`.
202
224
  */
203
225
  export type StepFn = ((filter?: Filter) => Chain) & {
@@ -209,7 +231,7 @@ export type StepFn = ((filter?: Filter) => Chain) & {
209
231
  export type Chain = ChainCore & {
210
232
  [className: string]: StepFn;
211
233
  };
212
- /** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение слота. */
234
+ /** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение шага роли (`.Роль.set(цепочка)`). */
213
235
  export type ChainRef = Pick<ChainCore, 'ids'>;
214
236
  /** Внутренний канал: план чужой ленивой цепочки (entity()). */
215
237
  export declare const PLAN: unique symbol;
@@ -220,6 +242,11 @@ export declare function lookupStep(reg: Registry, name: string): {
220
242
  cls: ClassInfo;
221
243
  role?: string;
222
244
  } | undefined;
245
+ /**
246
+ * Цепочка в читаемом виде: `Shop({"name":"A"}).Product.limit(10).rows()`. Фильтры — значениями (поля-секреты — '***'),
247
+ * данные записи — только именами полей.
248
+ */
249
+ export declare function chainText(log: Log, steps: readonly Step[], mods?: ChainMods, term?: string, args?: readonly unknown[]): string;
223
250
  export declare function makeChain(ctx: ChainCtx, steps: Step[], mods: ChainMods, nodes?: NodeMap): Chain;
224
251
  /**
225
252
  * Батч: очередь планов; каждый вызов класса — своя ссылка на очередь (свой план). run() исполняет очередь