letopis 1.3.0 → 1.4.1

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