letopis 3.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 (50) hide show
  1. package/AGENT-CHEATSHEET.md +12 -5
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +202 -46
  4. package/api-contract.json +4 -0
  5. package/dist/acl.d.ts +4 -2
  6. package/dist/admin.d.ts +5 -0
  7. package/dist/admin.js +10 -5
  8. package/dist/auth.d.ts +29 -8
  9. package/dist/auth.js +54 -9
  10. package/dist/chain.d.ts +19 -13
  11. package/dist/chain.js +17 -7
  12. package/dist/cli.js +46 -16
  13. package/dist/errors.d.ts +4 -5
  14. package/dist/errors.js +6 -7
  15. package/dist/index.d.ts +24 -13
  16. package/dist/index.js +75 -24
  17. package/dist/load.d.ts +1 -1
  18. package/dist/log.js +23 -2
  19. package/dist/mcp.d.ts +1 -1
  20. package/dist/mcp.js +8 -5
  21. package/dist/messages/access.d.ts +10 -0
  22. package/dist/messages/access.js +13 -0
  23. package/dist/messages/api.d.ts +10 -0
  24. package/dist/messages/api.js +23 -10
  25. package/dist/messages/boot.d.ts +5 -0
  26. package/dist/messages/boot.js +7 -0
  27. package/dist/messages/codes.js +2 -2
  28. package/dist/messages/log.d.ts +8 -5
  29. package/dist/messages/log.js +8 -5
  30. package/dist/messages/mcp.js +7 -7
  31. package/dist/messages/model.d.ts +16 -0
  32. package/dist/messages/model.js +20 -2
  33. package/dist/messages/write.d.ts +5 -0
  34. package/dist/messages/write.js +22 -14
  35. package/dist/model.d.ts +6 -2
  36. package/dist/model.js +5 -1
  37. package/dist/registry.d.ts +6 -0
  38. package/dist/registry.js +6 -0
  39. package/dist/sync.d.ts +3 -4
  40. package/dist/sync.js +3 -4
  41. package/dist/typed.d.ts +6 -6
  42. package/dist/types.d.ts +14 -9
  43. package/dist/types.js +2 -2
  44. package/dist/up.d.ts +5 -2
  45. package/dist/up.js +14 -6
  46. package/dist/write.d.ts +13 -9
  47. package/dist/write.js +81 -12
  48. package/llms.txt +2 -1
  49. package/package.json +1 -1
  50. package/sql/60-write.sql +18 -6
package/dist/acl.d.ts CHANGED
@@ -51,7 +51,7 @@ export interface AclRuleSpec {
51
51
  /** Правило арендатора в виде обёртки. */
52
52
  export interface AclRule {
53
53
  id: string;
54
- /** Роль группы `role:<роль>:ACCOUNT`; null — группа не одной роли (все аккаунты или своя группа). */
54
+ /** Роль правила; null — правило не одной роли (все аккаунты или своя группа). */
55
55
  role: string | null;
56
56
  /** Группа всех аккаунтов арендатора: `authenticated:ACCOUNT` от createTenant или `everyone:ACCOUNT` обёртки. */
57
57
  everyone: boolean;
@@ -87,7 +87,9 @@ export interface AclApi {
87
87
  /** Запретить — те же правила с запретом: при равном весе запрет сильнее разрешения. */
88
88
  deny(spec: AclRuleSpec): Promise<AclRule[]>;
89
89
  /**
90
- * Снять правила роли или всех аккаунтов — разрешения и запреты — для этих операций и целей; число снятых.
90
+ * Снять правила роли или всех аккаунтов — разрешения и запреты — для этих операций и целей; возвращает число снятых
91
+ * правил. Операции снимаются по очереди, и правило на несколько операций считается один раз: «можно всё» на чтение,
92
+ * запись и удаление от createTenant даёт 1.
91
93
  * `revoke({ everyone: true, read: '*', write: '*', delete: '*' })` снимает «можно всё» от createTenant: дальше права —
92
94
  * только по ролям.
93
95
  */
package/dist/admin.d.ts CHANGED
@@ -30,6 +30,11 @@ export interface AdminCtx {
30
30
  export declare function maintain(a: AdminCtx): Promise<MaintainReport>;
31
31
  /** Предупреждение о заполненной очереди сигналов (§2.12): больше половины — зависший слушатель. */
32
32
  export declare function queueWarning(queue: number): string | null;
33
+ /**
34
+ * Короткая форма интервала — '500ms', '30s', '15m', '1h', '1d' (дробное число тоже) — в миллисекундах; другая строка —
35
+ * null. Общая для connect({ maintain }) и срока ttl у auth (3.0.1).
36
+ */
37
+ export declare function shortIntervalMs(v: string): number | null;
33
38
  /**
34
39
  * Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
35
40
  * суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
package/dist/admin.js CHANGED
@@ -33,6 +33,14 @@ export function queueWarning(queue) {
33
33
  }
34
34
  /** Самый длинный интервал таймера Node.js — 2³¹−1 мс (около 24,8 суток): дольше таймер не ждёт и срабатывает сразу. */
35
35
  const MAX_TIMER_MS = 2 ** 31 - 1;
36
+ /**
37
+ * Короткая форма интервала — '500ms', '30s', '15m', '1h', '1d' (дробное число тоже) — в миллисекундах; другая строка —
38
+ * null. Общая для connect({ maintain }) и срока ttl у auth (3.0.1).
39
+ */
40
+ export function shortIntervalMs(v) {
41
+ const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
42
+ return m ? Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]] : null;
43
+ }
36
44
  /**
37
45
  * Интервал расписания: число мс или строка '500ms', '30s', '15m', '1h', '1d' — конечный, от 1 мс до 2³¹−1 мс (около 24
38
46
  * суток); иначе invalid_data. Ноль, отрицательное, NaN и слишком длинный интервал таймер Node.js превращает в 1 мс —
@@ -42,11 +50,8 @@ export function parseInterval(v) {
42
50
  let ms = Number.NaN;
43
51
  if (typeof v === 'number')
44
52
  ms = v;
45
- else if (typeof v === 'string') {
46
- const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(v.trim());
47
- if (m)
48
- ms = Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
49
- }
53
+ else if (typeof v === 'string')
54
+ ms = shortIntervalMs(v) ?? Number.NaN;
50
55
  if (!Number.isFinite(ms) || ms < 1 || ms > MAX_TIMER_MS) {
51
56
  const shown = typeof v === 'string' ? `'${v}'` : typeof v === 'number' || typeof v === 'boolean' || v == null ? String(v) : typeof v;
52
57
  throw lerr('time.bad_interval', { value: shown });
package/dist/auth.d.ts CHANGED
@@ -11,6 +11,17 @@ export declare function totpCode(secretBase32: string, atMs?: number): string;
11
11
  type AccountRef = string | {
12
12
  id: string;
13
13
  };
14
+ /**
15
+ * Срок ttl (3.0.1) — интервал для базы: число секунд, короткая форма как у connect({ maintain }) ('30s', '15m', '2h',
16
+ * '1d') или строка-интервал PostgreSQL ('15 minutes') — её разбирает база, ошибку разбора переводит ttlGuard. Не задан —
17
+ * null (срок по умолчанию). Не число больше нуля, пустая строка, другой тип — invalid_data (access.bad_ttl).
18
+ */
19
+ export declare function ttlOf(v: unknown, where: string): string | null;
20
+ /**
21
+ * Строка ttl, которую не принял разбор интервала PostgreSQL (22007 — не интервал, 22008 и 22015 — вне диапазона), —
22
+ * invalid_data access.bad_ttl вместо сырой ошибки базы; прочие ошибки — как есть.
23
+ */
24
+ export declare function ttlGuard<T>(run: Promise<T>, ttl: unknown, where: string): Promise<T>;
14
25
  /** Успешный вход: аккаунт и токен новой сессии. */
15
26
  export interface LoginResult {
16
27
  account: string;
@@ -46,6 +57,10 @@ export interface Credential {
46
57
  created_at: string;
47
58
  expires_at: string | null;
48
59
  }
60
+ /**
61
+ * Вход, креды и сессии (`db.auth`). Срок `ttl` у входа, ключей, кодов и сессий — число секунд или строка: короткая форма
62
+ * `'15m'`/`'2h'`/`'30s'`/`'1d'` либо интервал PostgreSQL `'15 minutes'`; иное — `invalid_data`.
63
+ */
49
64
  export interface AuthApi {
50
65
  /** Пароль (и любой «логин/пароль»: `kind` — `PASSWORD`, `EMAIL`, …): создать или заменить. Смена гасит остальные сессии аккаунта. */
51
66
  setPassword(a: {
@@ -66,14 +81,14 @@ export interface AuthApi {
66
81
  password: string;
67
82
  kind?: string;
68
83
  requireConfirmed?: boolean;
69
- ttl?: number;
84
+ ttl?: number | string;
70
85
  totp?: string;
71
86
  }): Promise<LoginResult | null>;
72
87
  /** Ключ API `lts_…`: отдаётся один раз. Себе сервис выпускает ключ сам по сессии своего ключа. */
73
88
  issueApiKey(a: {
74
89
  account: AccountRef;
75
90
  name?: string;
76
- ttl?: number;
91
+ ttl?: number | string;
77
92
  }): Promise<{
78
93
  key: string;
79
94
  id: string;
@@ -112,7 +127,7 @@ export interface AuthApi {
112
127
  account: AccountRef;
113
128
  identifier: string;
114
129
  kind?: string;
115
- ttl?: number;
130
+ ttl?: number | string;
116
131
  digits?: number;
117
132
  maxAttempts?: number;
118
133
  }): Promise<{
@@ -147,7 +162,7 @@ export interface AuthApi {
147
162
  kind: string;
148
163
  identifier: string;
149
164
  requireConfirmed?: boolean;
150
- ttl?: number;
165
+ ttl?: number | string;
151
166
  totp?: string;
152
167
  }): Promise<LoginResult | null>;
153
168
  /** Способы входа (креды) аккаунта — без хэшей и секретов. */
@@ -165,9 +180,12 @@ export interface AuthApi {
165
180
  confirmed?: boolean;
166
181
  enabled?: boolean;
167
182
  }): Promise<boolean>;
168
- /** Сессия пользователя для другого процесса (право `auth.session`); срок — `ttl` в секундах, по умолчанию 7 суток. */
183
+ /**
184
+ * Сессия пользователя для другого процесса (право `auth.session`). Срок `ttl` — число секунд или строка: короткая форма
185
+ * `'15m'`/`'2h'`/`'30s'`/`'1d'` либо интервал PostgreSQL `'15 minutes'`; по умолчанию 7 суток.
186
+ */
169
187
  sessionFor(account: AccountRef, opts?: {
170
- ttl?: number;
188
+ ttl?: number | string;
171
189
  }): Promise<string>;
172
190
  /**
173
191
  * Сведения о сессии по её токену — для сервиса, который держит токен пользователя (например, в cookie): чья это
@@ -181,9 +199,12 @@ export interface AuthApi {
181
199
  * `no_session` — у подключения нет действующей сессии.
182
200
  */
183
201
  session(token: string): Promise<SessionInfo | null>;
184
- /** Продлить текущую сессию; новый срок — от сейчас. */
202
+ /**
203
+ * Продлить текущую сессию; новый срок — от сейчас. `ttl` — число секунд или строка: короткая форма `'15m'`/`'2h'`/`'30s'`/`'1d'`
204
+ * либо интервал PostgreSQL `'15 minutes'`; по умолчанию 7 суток.
205
+ */
185
206
  refresh(opts?: {
186
- ttl?: number;
207
+ ttl?: number | string;
187
208
  }): Promise<string>;
188
209
  /**
189
210
  * Выход. Без аргумента — погасить сессию этого подключения. С токеном — погасить сессию пользователя по её токену
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;
@@ -85,7 +86,44 @@ export function totpCode(secretBase32, atMs = Date.now()) {
85
86
  const accId = (a) => (typeof a === 'object' ? a.id : a);
86
87
  /** Логин без крайних пробелов (план 2.1, A12): « anna » и «anna» — один логин; не строка — как есть (отказ даст база). */
87
88
  const trimLogin = (v) => (typeof v === 'string' ? v.trim() : v);
88
- const ttlOf = (sec) => (sec === undefined ? null : `${Math.round(sec)} seconds`);
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
+ }
89
127
  /** Фасад db.auth. onSwitch — подключение перечитывает арендатора, реестр и пропуска прав. */
90
128
  export function makeAuth(S, exec, onSwitch, canSwitch) {
91
129
  const one = async (text, params = []) => {
@@ -95,7 +133,8 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
95
133
  // Вход после первой проверки. Код TOTP проверяет база той же функцией, что verifyTotp (право auth.totp,
96
134
  // окно ±1 шаг, повтор шага, счётчик неудач и блокировка), секрет из базы не выходит; способ TOTP
97
135
  // сессия получает только после удачи. Без кода — сессия без отметки TOTP, как раньше.
98
- const login = async (account, ttl, methods, totp) => {
136
+ const login = async (account, ttl, methods, totp, where = 'auth') => {
137
+ const interval = ttlOf(ttl, where);
99
138
  if (!account)
100
139
  return null;
101
140
  if (totp != null) {
@@ -104,7 +143,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
104
143
  return null;
105
144
  methods = [...methods, 'TOTP'];
106
145
  }
107
- 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);
108
147
  return { account, token: r.t };
109
148
  };
110
149
  return {
@@ -114,6 +153,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
114
153
  return r.id;
115
154
  },
116
155
  async verifyPassword(a) {
156
+ ttlOf(a.ttl, 'verifyPassword');
117
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];
118
158
  // логин без крайних пробелов (план 2.1, A12); кред, записанный до 2.1 с пробелами, находится по логину как есть
119
159
  const ident = trimLogin(a.identifier);
@@ -124,10 +164,10 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
124
164
  return null;
125
165
  if (!cred.confirmed && a.requireConfirmed !== false)
126
166
  return null;
127
- return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp);
167
+ return login(cred.account, a.ttl, [a.kind ?? 'PASSWORD'], a.totp, 'verifyPassword');
128
168
  },
129
169
  async issueApiKey(a) {
130
- 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');
131
171
  },
132
172
  async verifyApiKey(key) {
133
173
  return (await one(`select ${S}.auth_login_apikey($1) as r`, [key])).r;
@@ -152,7 +192,11 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
152
192
  return (await one(`select ${S}.auth_totp_enabled($1::uuid) as ok`, [accId(account)])).ok;
153
193
  },
154
194
  async issueOtp(a) {
155
- 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');
156
200
  return { code: r.c };
157
201
  },
158
202
  async verifyOtp(a) {
@@ -162,8 +206,9 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
162
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;
163
207
  },
164
208
  async lookup(a) {
209
+ ttlOf(a.ttl, 'lookup');
165
210
  const r = await one(`select ${S}.auth_lookup($1, $2, $3) as a`, [a.kind, a.identifier, a.requireConfirmed ?? true]);
166
- return login(r.a, a.ttl, [a.kind], a.totp);
211
+ return login(r.a, a.ttl, [a.kind], a.totp, 'lookup');
167
212
  },
168
213
  async credentials(account) {
169
214
  return exec.run((q) => q.unsafe(`select * from ${S}.auth_credentials($1::uuid)`, [accId(account)]));
@@ -175,7 +220,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
175
220
  return (await one(`select ${S}.auth_set_flags($1::uuid, $2, $3) as ok`, [id, flags.confirmed ?? null, flags.enabled ?? null])).ok;
176
221
  },
177
222
  async sessionFor(account, opts) {
178
- return (await login(accId(account), opts?.ttl, ['SERVICE'])).token;
223
+ return (await login(accId(account), opts?.ttl, ['SERVICE'], undefined, 'sessionFor')).token;
179
224
  },
180
225
  async session(token) {
181
226
  const r = (await one(`select ${S}.auth_session_info($1) as r`, [token])).r;
@@ -183,7 +228,7 @@ export function makeAuth(S, exec, onSwitch, canSwitch) {
183
228
  return r && { account: r.account, tenant: r.tenant, member: r.member, expiresAt: r.expiresAt, loginAt: r.loginAt, methods: r.methods };
184
229
  },
185
230
  async refresh(opts) {
186
- 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;
187
232
  },
188
233
  async revoke(token) {
189
234
  // с токеном — выход пользователя по токену (план 2.1, A3): право auth.session, как у session(token)
package/dist/chain.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface ChainCtx {
12
12
  readonly exec: Executor;
13
13
  /** Арендатор сессии. */
14
14
  readonly tenant: () => string | null;
15
+ /** Аккаунт сессии (с имперсонацией — тот, от чьего имени): арендатор равен ему — личный арендатор. */
16
+ readonly account?: () => string | null;
15
17
  /** Фасад входит по токену сессии (connect({ token }), ключ API): без арендатора сессии шаг — sessionless(). */
16
18
  readonly hasToken?: () => boolean;
17
19
  /** Владелец новых строк по умолчанию (db.as(…, { owner })). */
@@ -59,7 +61,7 @@ export type Batch = {
59
61
  };
60
62
  export interface ChainCore {
61
63
  /**
62
- * Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные ссылками; приходит объектом, где ключ — имя
64
+ * Все пути цепочки. Путь — строки всех шагов, по одной на шаг, связанные концами; приходит объектом, где ключ — имя
63
65
  * шага (или имя из `alias()`), а значение — строка этого шага: `{ Shop: …, Product: … }`. Если к объекту последнего
64
66
  * шага ведут несколько путей, придут все.
65
67
  */
@@ -71,12 +73,12 @@ export interface ChainCore {
71
73
  rows(): Promise<Row[]>;
72
74
  first(): Promise<Row | null>;
73
75
  ids(): Promise<string[]>;
74
- /** Число сущностей последнего шага; { paths: true } — число путей. */
76
+ /** Число объектов последнего шага; { paths: true } — число путей. */
75
77
  count(opts?: {
76
78
  paths?: boolean;
77
79
  }): Promise<number>;
78
80
  /**
79
- * Все версии сущностей последнего шага (надгробия — $deleted), по id и номеру версии. Шаг с явными id находит и
81
+ * Все версии объектов последнего шага (надгробия — $deleted), по id и номеру версии. Шаг с явными id находит и
80
82
  * удалённые объекты: `db.Класс(id).versions()` отдаёт историю удалённого.
81
83
  */
82
84
  versions(): Promise<Row[]>;
@@ -110,10 +112,14 @@ export interface ChainCore {
110
112
  /**
111
113
  * Не больше n строк ответа: объектов последнего шага, у `paths()` — путей, у `versions()` — версий, перед `countBy()` —
112
114
  * групп. До глагола сужает его цели. Сразу после глагола перед `rows`, `first`, `ids`, `count` и `paths` —
113
- * `invalid_query`: глагол уже прошёл по всем целям, сужать нечего.
115
+ * `invalid_query`: глагол уже прошёл по всем целям, сужать нечего. `n` — целое от 0: дробное — `invalid_query`,
116
+ * отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201W).
114
117
  */
115
118
  limit(n: number): Chain;
116
- /** Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. */
119
+ /**
120
+ * Пропустить первые n строк ответа — тех же, что считает `limit()`; где действует — как у `limit()`. `n` — целое от 0:
121
+ * дробное — `invalid_query`, отрицательное отклоняет PostgreSQL (ошибка базы с SQLSTATE 2201X).
122
+ */
117
123
  offset(n: number): Chain;
118
124
  /** Сортировка по полю последнего шага: 'at' | 'rev' | 'id' | 'data.<путь>'. */
119
125
  sort(field: string, dir?: 'asc' | 'desc' | boolean): Chain;
@@ -163,7 +169,7 @@ export interface ChainCore {
163
169
  * следующий шаг пойдёт от него.
164
170
  */
165
171
  entity(x: ChainRef | Row): Chain;
166
- /** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
172
+ /** Создать объект: концы — из пути и шагов роли; занятый ключ — ошибка exists с id. */
167
173
  create(data?: Record<string, unknown>, opts?: {
168
174
  reason?: string;
169
175
  }): Chain;
@@ -183,10 +189,10 @@ export interface ChainCore {
183
189
  }): Chain;
184
190
  /**
185
191
  * Удаляет цели шага; строки, которые на них ссылаются, база удаляет вместе с ними, отвязывает или не даёт удалить цель —
186
- * как задано в `onDelete` их ссылок. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
187
- * вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. `{ confirm: true, expect: превью }`
188
- * удаляет ровно показанное: в транзакции удаления превью строится заново и сравнивается с `expect` по `id`, `rev` и
189
- * `$action`; расхождение — `conflict` (ничего не удалено, новое превью — `err.detail.preview`); цели сужают фильтр, id,
192
+ * как задано в `onDelete` их концов. Без `{ confirm: true }` — предпросмотр удаления: ничего не удаляется, терминал
193
+ * вернёт все строки, которых оно коснулось бы, у каждой `$action` — что с ней будет. `{ confirm: true, expect: предпросмотр }`
194
+ * удаляет ровно показанное: в транзакции удаления предпросмотр строится заново и сравнивается с `expect` по `id`, `rev` и
195
+ * `$action`; расхождение — `conflict` (ничего не удалено, новый предпросмотр — `err.detail.preview`); цели сужают фильтр, id,
190
196
  * `tags()`, `owner()` или `limit()` хотя бы у одного шага, иначе `invalid_query`. `{ confirm: true }` без `expect` —
191
197
  * `invalid_query` (`write.confirm_without_expect`); осознанно удалить текущий состав целей без сверки (и без фильтра —
192
198
  * все строки) — `{ confirm: true, all: true }`.
@@ -212,8 +218,8 @@ export interface ChainCore {
212
218
  }
213
219
  /**
214
220
  * Шаг цепочки — имя класса, его алиаса или роли с необязательным фильтром: `db.Shop(id).Product({ price: gt(1000) })`;
215
- * каждый следующий шаг переходит по ссылкам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
216
- * роли без вызова задаёт ссылку записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественной ссылки ещё
221
+ * каждый следующий шаг переходит по концам от объектов предыдущего. После `create()`, `update()` или `upsert()` имя
222
+ * роли без вызова задаёт конец записываемой строки: `.Роль.set(…)`, `.Роль.unset()`, у множественного конца ещё
217
223
  * `.Роль.add(…)` и `.Роль.remove(…)`.
218
224
  */
219
225
  export type StepFn = ((filter?: Filter) => Chain) & {
@@ -225,7 +231,7 @@ export type StepFn = ((filter?: Filter) => Chain) & {
225
231
  export type Chain = ChainCore & {
226
232
  [className: string]: StepFn;
227
233
  };
228
- /** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение слота. */
234
+ /** Любая цепочка — с типами по моделям (`TypedChain`) или без них (`Chain`): аргумент `entity()` и значение шага роли (`.Роль.set(цепочка)`). */
229
235
  export type ChainRef = Pick<ChainCore, 'ids'>;
230
236
  /** Внутренний канал: план чужой ленивой цепочки (entity()). */
231
237
  export declare const PLAN: unique symbol;
package/dist/chain.js CHANGED
@@ -52,13 +52,21 @@ const stepNames = (reg) => reg.all().filter((c) => !isSystem(c)).flatMap((c) =>
52
52
  */
53
53
  const ARRAY_PROPS = new Set(['length', 'map', 'filter', 'forEach', 'reduce', 'find', 'some', 'every', 'slice', 'at', 'toJSON', '0']);
54
54
  /**
55
- * Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). Свойство массива — api.chain_not_rows;
56
- * в реестре только классы System — сессия, похоже, в личном арендаторе (api.unknown_step_no_classes).
55
+ * Неизвестный класс или роль — с похожими именами реестра (план 2.0, 7.9 №6). Свойство массива — api.chain_not_rows.
56
+ * В реестре только классы System (3.0.1): сессия в личном арендаторе (арендатор = аккаунт сессии) —
57
+ * api.unknown_step_no_classes, иначе в арендаторе просто нет классов — api.unknown_step_empty_tenant.
57
58
  */
58
- const unknownName = (reg, name) => {
59
+ const unknownName = (ctx, name) => {
60
+ const reg = ctx.reg;
59
61
  if (ARRAY_PROPS.has(name))
60
62
  throw lerr('api.chain_not_rows', { name });
61
- const key = reg.all().every((c) => c.tenant === SYSTEM_ID) ? 'api.unknown_step_no_classes' : 'api.unknown_step';
63
+ let key = 'api.unknown_step';
64
+ if (reg.all().every((c) => c.tenant === SYSTEM_ID)) {
65
+ const tenant = ctx.tenant();
66
+ const account = ctx.account?.() ?? null;
67
+ key = tenant !== null && account !== null && tenant.toLowerCase() === account.toLowerCase()
68
+ ? 'api.unknown_step_no_classes' : 'api.unknown_step_empty_tenant';
69
+ }
62
70
  throw lerr(key, { name, similar: similar(name, stepNames(reg)) });
63
71
  };
64
72
  /** Терминалы и агрегаты цепочки: у цепочки без сессии они отдают отказ промисом (sessionless). */
@@ -288,10 +296,12 @@ function writeDetail(op, rows) {
288
296
  d.locked = true;
289
297
  if (rows && !op.confirm) {
290
298
  const actions = {};
299
+ // строки-сводки скрытых правами строк (3.0.1): в actions.hidden — их число, в классы и id сводки не идут
291
300
  for (const r of rows)
292
301
  if (r.$action)
293
- actions[r.$action] = (actions[r.$action] ?? 0) + 1;
302
+ actions[r.$action] = (actions[r.$action] ?? 0) + (r.$action === 'hidden' ? r.$count ?? 0 : 1);
294
303
  d.actions = actions;
304
+ rows = rows.filter((r) => r.$action !== 'hidden');
295
305
  }
296
306
  }
297
307
  else if (op.kind === 'restore') {
@@ -833,7 +843,7 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
833
843
  const found = lookupStep(ctx.reg, prop);
834
844
  if (!found && noSession(ctx, prop))
835
845
  return sessionless();
836
- return classProp(ctx, prop, found ?? unknownName(ctx.reg, prop), steps, mods, nodes);
846
+ return classProp(ctx, prop, found ?? unknownName(ctx, prop), steps, mods, nodes);
837
847
  };
838
848
  const log = ctx.log;
839
849
  const handler = {
@@ -906,7 +916,7 @@ export function startStep(ctx, name) {
906
916
  const found = lookupStep(ctx.reg, name);
907
917
  if (!found && noSession(ctx, name))
908
918
  return sessionless();
909
- return classProp(ctx, name, found ?? unknownName(ctx.reg, name), undefined, {}, NO_NODES);
919
+ return classProp(ctx, name, found ?? unknownName(ctx, name), undefined, {}, NO_NODES);
910
920
  }
911
921
  /** Старт пути с готового узла: db.entity(row | цепочка). */
912
922
  export function startEntity(ctx, x) {
package/dist/cli.js CHANGED
@@ -26,14 +26,15 @@ import { connect } from './index.js';
26
26
  import { syncModels, generateTypes } from './sync.js';
27
27
  import { lerr, lmsg } from './errors.js';
28
28
  import { stderrLog } from './log.js';
29
- const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply | --check]
30
- letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
31
- letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
29
+ const USAGE = `letopis sync <модуль> --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--apply | --check]
30
+ letopis types --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--out <файл>] [--system]
31
+ letopis load <файлы…> --dsn <строка> --schema <схема> (--token <токен> | --api-key <ключ>) [--class <Класс>]
32
32
  [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--initial]
33
- letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>]
34
- letopis mcp --dsn <строка> --schema <v2.имя> --token <токен пользователя> [--write]
33
+ letopis maintain --dsn <строка> --schema <схема> [--token <токен> | --api-key <ключ>]
34
+ letopis mcp --dsn <строка> --schema <схема> --token <токен пользователя> [--write]
35
35
  (или LETOPIS_DSN, LETOPIS_SCHEMA, LETOPIS_TOKEN; ключ сервиса --api-key не принимается)
36
- у любой команды: --log[=debug|info|warn|error|off] — события JSON-строками в stderr (без значения — info)`;
36
+ у любой команды: --log[=debug|info|warn|error|off] — события JSON-строками в stderr (без значения — info)
37
+ (<схема> — v2.<имя> или просто <имя>: shop — это v2.shop)`;
37
38
  /** Команды CLI: остальное — USAGE с кодом 2 ещё до подключения. */
38
39
  const COMMANDS = new Set(['sync', 'types', 'load', 'maintain', 'mcp']);
39
40
  const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
@@ -59,18 +60,47 @@ function logFlag(argv) {
59
60
  }
60
61
  return { args, log: level === undefined ? {} : { log: stderrLog, logLevel: level } };
61
62
  }
63
+ /** Флаги CLI для parseArgs. */
64
+ const OPTIONS = {
65
+ dsn: { type: 'string' }, schema: { type: 'string' }, token: { type: 'string' }, 'api-key': { type: 'string' },
66
+ apply: { type: 'boolean' }, out: { type: 'string' }, help: { type: 'boolean' },
67
+ class: { type: 'string' }, duplicates: { type: 'string' }, existing: { type: 'string' }, 'commit-every': { type: 'string' },
68
+ initial: { type: 'boolean' }, write: { type: 'boolean' }, system: { type: 'boolean' }, check: { type: 'boolean' },
69
+ };
70
+ /**
71
+ * Ошибка разбора аргументов parseArgs — русский текст каталога (английский текст Node — только для прочих ошибок);
72
+ * null — не ошибка разбора.
73
+ */
74
+ function parseError(e) {
75
+ const code = e?.code;
76
+ const message = String(e?.message ?? '');
77
+ // имя флага или аргумент — первое в кавычках: «Unknown option '--aply'…», «Option '--dsn <value>' argument missing»
78
+ const quoted = (/'([^']*)'/.exec(message)?.[1] ?? '').split(' ')[0] ?? '';
79
+ if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION')
80
+ return lmsg('model.cli_unknown_flag', { flag: quoted });
81
+ if (code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') {
82
+ // строковому флагу не дали значения или булевому дали
83
+ return lmsg(/does not take/i.test(message) ? 'model.cli_flag_no_value' : 'model.cli_flag_needs_value', { flag: quoted });
84
+ }
85
+ if (code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL')
86
+ return lmsg('model.cli_unexpected_arg', { arg: quoted });
87
+ return null;
88
+ }
62
89
  export async function main(argv) {
63
90
  const { args, log } = logFlag(argv);
64
- const { values, positionals } = parseArgs({
65
- args,
66
- allowPositionals: true,
67
- options: {
68
- dsn: { type: 'string' }, schema: { type: 'string' }, token: { type: 'string' }, 'api-key': { type: 'string' },
69
- apply: { type: 'boolean' }, out: { type: 'string' }, help: { type: 'boolean' },
70
- class: { type: 'string' }, duplicates: { type: 'string' }, existing: { type: 'string' }, 'commit-every': { type: 'string' },
71
- initial: { type: 'boolean' }, write: { type: 'boolean' }, system: { type: 'boolean' }, check: { type: 'boolean' },
72
- },
73
- });
91
+ let parsed;
92
+ try {
93
+ parsed = parseArgs({ args, allowPositionals: true, options: OPTIONS });
94
+ }
95
+ catch (e) {
96
+ const text = parseError(e);
97
+ if (text === null)
98
+ throw e;
99
+ // код выхода 1 — как у прочих ошибок до подключения (прежде — необработанное исключение parseArgs)
100
+ console.error(`letopis: ${text}`);
101
+ return 1;
102
+ }
103
+ const { values, positionals } = parsed;
74
104
  const [cmd, arg] = positionals;
75
105
  // клиенты MCP передают строку подключения и токен окружением (план 1.1, §2.5) — только для этой команды
76
106
  if (cmd === 'mcp') {
package/dist/errors.d.ts CHANGED
@@ -84,10 +84,9 @@ export declare function issuesFromDb(issues: unknown): Issue[];
84
84
  */
85
85
  export declare function findingsFromDb<T>(rows: readonly T[]): T[];
86
86
  /**
87
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
88
- * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
89
- * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
90
- * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
91
- * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
87
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями) — для ошибок сырого SQL (`db.sql`,
88
+ * `db.transaction()`): тот же код, ключ и подробности, что у цепочек. Подробности ошибки базы переходят в `detail`, ключ
89
+ * каталога и параметры — в `key` и `params`, текст — русский; без ключа — текст базы. Значение не того типа (22P02) —
90
+ * `invalid_query`. Повторяемый конфликт (40001, 40P01) и прочие ошибки возвращаются как есть.
92
91
  */
93
92
  export declare function fromDbError(e: unknown): unknown;
package/dist/errors.js CHANGED
@@ -2,10 +2,10 @@
2
2
  * Ошибки letopis 1.0 (план, §7.1, этап 2, п. 10). Текст — «letopis: <код>: <сообщение>», как у ошибок
3
3
  * базы; код — имя из каталога (`error_catalog()` в lib/sql/15-errors.sql) или код самой библиотеки.
4
4
  *
5
- * Каталог сообщений (план 2.0, этап 3, п. 3; lib/src/messages): ошибка по ключу — `lerr(ключ, параметры)`, текст —
5
+ * Каталог сообщений: ошибка по ключу — `lerr(ключ, параметры)`, текст —
6
6
  * шаблон записи с параметрами. У каждой ошибки — подсказка «что делать» (`hint`) и ссылка на строку её кода в разделе
7
7
  * «Ошибки» README на GitHub по тегу версии (`docs`). База собирает текст по ключу и параметрам из того же шаблона и
8
- * кладёт их в DETAIL (`lt_fail(ключ, …)`), а fromDbError переносит их в поля ошибки (`key`, `params`); без ключа —
8
+ * передаёт ключ и параметры вместе с ошибкой, а fromDbError переносит их в поля ошибки (`key`, `params`); без ключа —
9
9
  * текст базы как есть. У нарушений (validate_data) и строк отказа ужесточения key и params наружу не идут: issuesFromDb,
10
10
  * findingsFromDb.
11
11
  */
@@ -213,11 +213,10 @@ function parseDetail(detail) {
213
213
  }
214
214
  }
215
215
  /**
216
- * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями). Детали из DETAIL (JSON) переносятся в
217
- * detail; ключ каталога и параметры из DETAIL (lt_fail(ключ, …)) — в key и params, текст — тот, что база собрала по ним
218
- * из русского шаблона; без ключа — текст базы. У нарушений (issues) и строк отказа (detail.rows) key и params наружу не
219
- * идут. Внутренняя ошибка базы (XX000) с ключом в DETAIL — код internal. Повторяемый конфликт (40001, 40P01) и прочие
220
- * ошибки возвращаются как есть: на SQLSTATE конфликта держатся повторы (tx.ts).
216
+ * Ошибка базы → LetopisError (ValidationError для invalid_data с нарушениями) — для ошибок сырого SQL (`db.sql`,
217
+ * `db.transaction()`): тот же код, ключ и подробности, что у цепочек. Подробности ошибки базы переходят в `detail`, ключ
218
+ * каталога и параметры — в `key` и `params`, текст — русский; без ключа — текст базы. Значение не того типа (22P02) —
219
+ * `invalid_query`. Повторяемый конфликт (40001, 40P01) и прочие ошибки возвращаются как есть.
221
220
  */
222
221
  export function fromDbError(e) {
223
222
  const err = e;