letopis 1.4.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 (52) hide show
  1. package/AGENT-CHEATSHEET.en.md +10 -5
  2. package/AGENT-CHEATSHEET.md +14 -9
  3. package/CHANGELOG.md +159 -0
  4. package/README.en.md +119 -52
  5. package/README.md +107 -47
  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 +12 -5
  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 +30 -11
  28. package/dist/project.js +30 -4
  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/tx.js +10 -1
  34. package/dist/types.d.ts +14 -7
  35. package/dist/types.js +13 -6
  36. package/dist/up.d.ts +48 -25
  37. package/dist/up.js +89 -41
  38. package/dist/validate.d.ts +9 -2
  39. package/dist/validate.js +19 -3
  40. package/dist/watch.d.ts +17 -5
  41. package/dist/watch.js +40 -8
  42. package/dist/write.d.ts +3 -2
  43. package/dist/write.js +29 -10
  44. package/package.json +1 -1
  45. package/sql/10-core.sql +6 -1
  46. package/sql/60-write.sql +38 -18
  47. package/sql/80-auth.sql +39 -2
  48. package/sql/88-derived.sql +1 -0
  49. package/sql/90-time.sql +15 -5
  50. package/sql/95-seed.system.sql +6 -3
  51. package/sql/99-revision.sql +1 -1
  52. package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/errors.js CHANGED
@@ -12,13 +12,13 @@ export class LetopisError extends Error {
12
12
  this.detail = detail;
13
13
  }
14
14
  }
15
+ /** Версия, в которой снято старое API (её берёт и контракт API — scripts/gen-api-contract.mjs). */
16
+ export const REMOVED_IN = '1.0.0';
15
17
  /** Охранник снесённого API (план, этап 3, п. 11): понятная ошибка с версией и заменой. */
16
18
  export function removed(name, replacement) {
17
- return new LetopisError('removed', `${name} снят в 1.0.0 — ${replacement}`);
18
- }
19
- /** Метод зарезервирован, но появится на следующем этапе плана. */
20
- export function notYet(name, stage) {
21
- return new LetopisError('not_implemented', `${name} появится на этапе ${stage} плана 1.0`);
19
+ // «опция …» — женского рода; методы и свойства — мужского
20
+ const verb = name.startsWith('опция ') ? 'снята' : 'снят';
21
+ return new LetopisError('removed', `${name} ${verb} в ${REMOVED_IN} — ${replacement}`);
22
22
  }
23
23
  /** Данные не проходят описание класса (`invalid_data`). */
24
24
  export class ValidationError extends LetopisError {
package/dist/import.js CHANGED
@@ -625,7 +625,14 @@ export async function import021(opts) {
625
625
  }
626
626
  if (secretRows.length) {
627
627
  await x.begin(async (tx) => {
628
+ // кред аккаунта, которого в установке нет (его нет и в Account 0.21), привязать не к чему: строка secret ссылается
629
+ // на аккаунт внешним ключом (ревизия 7) — такой кред пропускается и считается в skipped
630
+ const known = new Set((await tx.unsafe(`select id::text as id from ${S}.entity where id = any ($1::uuid[])`, [[...new Set(secretRows.map((s) => s.account))]])).map((r) => r.id));
628
631
  for (const s of secretRows) {
632
+ if (!known.has(s.account)) {
633
+ report.credentials.skipped++;
634
+ continue;
635
+ }
629
636
  const [dup] = await tx.unsafe(`select account::text as account from ${S}.secret
630
637
  where kind = $1 and casefold(ident) collate pg_unicode_fast = casefold($2) collate pg_unicode_fast and deleted_at is null`, [s.kind, s.ident]);
631
638
  if (dup) {
package/dist/index.d.ts CHANGED
@@ -29,7 +29,7 @@ export { syncModels, generateTypes, schemaToTs, type SyncResult } from './sync.j
29
29
  export { ne, gt, gte, lt, lte, between, inList, like, ilike, starts, ends, has, hasAny, hasAll, exists, isNull, not, or, isOp } from './ops.js';
30
30
  export { cursorOf } from './sql.js';
31
31
  export { inc, isInc, type IncMarker, type WriteRow } from './write.js';
32
- export { totpCode, hashPassword, verifyHash, type AuthApi, type LoginResult, type Credential } from './auth.js';
32
+ export { totpCode, hashPassword, verifyHash, type AuthApi, type LoginResult, type Credential, type SessionInfo } from './auth.js';
33
33
  export type { AclApi, AclDecision, AclDataDecision, AclOp } from './acl.js';
34
34
  export type { LoadOptions, LoadReport, LoadIssue, LoadInput } from './load.js';
35
35
  export type { Chain, ChainCore, StepFn, BatchEntry } from './chain.js';
@@ -54,36 +54,41 @@ export interface ConnectOptions {
54
54
  sql?: postgres.Sql;
55
55
  /** Схема установки, например `v2.salon`. */
56
56
  schema: string;
57
- /** Токен сессии; либо ключ APIKEY в `apiKey` (он же `service` — ключ сервиса) — его обменяют на сессию. */
57
+ /** Токен сессии; либо ключ `APIKEY` в `apiKey` (он же `service` — ключ сервиса) — его обменяют на сессию. */
58
58
  token?: string;
59
59
  apiKey?: string;
60
60
  service?: string;
61
61
  /** Продлевать сессию раз в минуту работы (по умолчанию да, §2.9). */
62
62
  refreshSession?: boolean;
63
- /** Разрешить роль, которая обходит RLS (суперпользователь, BYPASSRLS, член роли-владельца), — администраторское подключение (§2.9). */
63
+ /** Разрешить роль, которая обходит RLS (суперпользователь, `BYPASSRLS`, член роли-владельца), — администраторское подключение (§2.9). */
64
64
  allowBypassRls?: boolean;
65
- /** Имя роли-владельца (по умолчанию letopis_owner). */
65
+ /** Имя роли-владельца (по умолчанию `letopis_owner`). */
66
66
  owner?: string;
67
67
  /** Интервал отправки отложенного сигнала, мс (по умолчанию 100; решение 9 точки А). */
68
68
  notifyIntervalMs?: number;
69
- /** Размер пула, если подключение создаёт сам connect(). */
69
+ /** Размер пула, если подключение создаёт сам `connect()`. */
70
70
  max?: number;
71
71
  /** Хук на каждый запрос цепочки (метрики, лог). */
72
72
  onQuery?: (e: QueryEvent) => void;
73
- /** Порог медленного запроса, мс: событие получает slow; без onQuery — console.warn. */
73
+ /** Порог медленного запроса, мс: событие получает `slow`; без `onQuery` — `console.warn`. */
74
74
  slowMs?: number;
75
- /** Модели letopis/model — только для типов цепочек (TypedDb); описания классов берутся из базы. */
75
+ /** Модели `letopis/model` — только для типов цепочек (`TypedDb`); описания классов берутся из базы. */
76
76
  models?: readonly AnyModelLike[];
77
77
  /**
78
- * Слушать сигналы базы (LISTEN, по умолчанию да). false — пул в режиме транзакций (PgBouncer):
79
- * реестр перечитывается через db.refresh(), подписка опрашивает журнал, кэш результатов выключен
80
- * (с cache: { ttlOnly: true } — работает только по сроку). В установке с up({ signals: 'none' })
81
- * слушателя нет всегда, а реестр раз в 5 с сверяет отпечаток классов.
78
+ * Слушать сигналы базы (`LISTEN`). `false` — для пула в режиме транзакций (PgBouncer): тогда реестр классов сам не
79
+ * обновляется (новые и изменённые классы подключение увидит после `db.refresh()`), подписка `watch()` только опрашивает
80
+ * журнал, а кэш результатов работает лишь с `cache: { ttlOnly: true }` — по сроку. В установке с
81
+ * `up({ signals: 'none' })` так всегда, только изменения классов подключение раз в 5 секунд проверяет само.
82
82
  */
83
83
  listen?: boolean;
84
- /** Кэш результатов (§2.12): срок по умолчанию, размер, ttlOnly; false — выключен. */
84
+ /** Кэш результатов (§2.12): срок по умолчанию, размер, `ttlOnly`; `false` — выключен. */
85
85
  cache?: CacheOptions | false;
86
- /** Запускать maintain() по расписанию ('1h', '15m', мс) под блокировкой обслуживания: работает один процесс. */
86
+ /**
87
+ * Запускать `maintain()` по расписанию: интервал — число миллисекунд или строка вроде `'15m'`, `'1h'`, от 1 мс до
88
+ * 2³¹−1 мс (около 24 суток); иначе `connect()` отказывает с `invalid_data`, не открывая соединений. Если обслуживание в
89
+ * этот момент уже ведёт другой процесс или ещё идёт прошлый запуск, запуск пропускается. `close()` ждёт, пока идущий
90
+ * запуск доделает текущую пачку.
91
+ */
87
92
  maintain?: string | number;
88
93
  }
89
94
  export interface DbCore {
@@ -92,50 +97,72 @@ export interface DbCore {
92
97
  /** Арендатор сессии. */
93
98
  readonly tenant: string | null;
94
99
  readonly registry: Registry;
95
- /** Креды, вход и сессии (этап 5). */
100
+ /**
101
+ * Вход и сессии: способы входа (креды) — пароль, ключ API, TOTP, одноразовый код из письма или СМС, привязка внешней
102
+ * учётной записи; вход по ним, выпуск и гашение сессий, выбор арендатора сессии.
103
+ */
96
104
  readonly auth: AuthApi;
97
- /** Решения прав: check(endpoint), checkData(class, op) (этап 5). */
105
+ /**
106
+ * Права того, от чьего имени идёт запрос, — заранее, для интерфейса и проверок в приложении: `check(endpoint)` — можно
107
+ * ли обратиться к ресурсу приложения, `checkData(класс, операция)` — можно ли читать, менять или удалять объекты
108
+ * класса. Сами права база применяет при каждом запросе.
109
+ */
98
110
  readonly acl: AclApi;
99
- /** Сырой SQL под сессией и RLS, одной транзакцией (в db.begin() — в ней): db.sql`select …`. */
111
+ /** Сырой SQL под сессией и RLS, одной транзакцией (в `db.begin()` — в ней): `` db.sql`select …` ``. */
100
112
  sql(strings: TemplateStringsArray, ...values: unknown[]): Promise<postgres.Row[]>;
101
- /** Транзакция под сессией: fn получает соединение postgres.js (в db.begin() — ту же транзакцию). */
113
+ /** Транзакция под сессией: `fn` получает соединение postgres.js (в `db.begin()` — ту же транзакцию). */
102
114
  transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
103
- /** id объекта класса с ключом в арендаторе сессии. */
115
+ /** id объекта по классу и значениям ключа — такой же, какой вычислит база в арендаторе сессии. */
104
116
  idOf(cls: string, key: Readonly<Record<string, unknown>>): string;
105
117
  /** Перечитать описания классов. */
106
118
  refresh(): Promise<void>;
107
119
  /** Отправить накопленные сигналы сейчас. */
108
120
  flushSignals(): Promise<void>;
109
121
  /**
110
- * Проверка цепочек хэшей и якорь (хэш всех голов); { data: true } — ещё данные, концы, арендаторы,
111
- * владельцы и id всех строк. Только системный администратор или администраторское подключение.
122
+ * Проверяет целостность журнала всей установки: сходится ли хэш каждой версии с хэшем предыдущей и текущая строка
123
+ * объекта — с его последней версией; с `{ data: true }` проверяет ещё каждую строку. Якорь в ответе — один хэш текущих
124
+ * версий всех объектов: храните его вне базы, тогда заметна и подмена самых свежих версий. Нужно право `letopis.verify`
125
+ * (по умолчанию оно у системного администратора) или администраторское подключение.
112
126
  */
113
127
  verify(opts?: {
114
128
  data?: boolean;
115
129
  }): Promise<VerifyResult>;
116
130
  /** Обслуживание: истёкшие сессии и коды, прореживание журнала по политике хранения, очередь сигналов. */
117
131
  maintain(opts?: MaintainOptions): Promise<MaintainReport>;
118
- /** Сброс sessions | tenant | all (allowReset установки, право letopis.reset, confirm — имя схемы). */
132
+ /**
133
+ * Сбрасывает тестовую установку: сессии, данные одного арендатора или всё — смотря по `level`. Работает, только если
134
+ * установка поставлена с `up({ allowReset: true })`, у вызывающего есть право `letopis.reset` (или подключение
135
+ * администраторское), а `confirm` совпадает с именем схемы.
136
+ */
119
137
  reset(opts: ResetOptions): Promise<ResetResult>;
120
- /** Обрезка истории до момента: класса (с потомками), id, списка id или строк; версия 1 → запись trim. */
138
+ /**
139
+ * Стирает версии, сделанные раньше момента `before`; текущая версия остаётся всегда. Вместо стёртого начала истории
140
+ * остаётся служебная запись `trim` со временем появления объекта.
141
+ */
121
142
  trimHistory(target: string | string[] | Row | Row[], before: string | Date): Promise<number>;
122
- /** Новый ключ класса: строки семейства переносятся на новые id, ссылки переводятся. */
143
+ /**
144
+ * Меняет ключ класса. Объекты его семейства (сам класс и потомки), у которых по новому ключу выходит другой id,
145
+ * переезжают на новый id, а ссылки на них переводятся.
146
+ */
123
147
  rekeyClass(cls: string, key: string[]): Promise<number>;
124
- /** Машинное описание модели с метаданными классов (describeText — то же текстом, llms.txt). */
148
+ /**
149
+ * Описание модели для программ и языковых моделей: классы арендатора сессии и общие классы System с полями, ключом,
150
+ * ссылками и метаданными `meta`. `describeText(db.describe())` превращает его в текст.
151
+ */
125
152
  describe(): ModelDescription;
126
- /** Подписка по курсору: for await (const ev of db.watch({ from })) …; ev.cursor — откуда продолжить. */
153
+ /** Подписка по курсору: `for await (const ev of db.watch({ from })) …`; `sub.cursor` — откуда продолжить. */
127
154
  watch(opts?: WatchOptions): Subscription;
128
155
  /** Кэш результатов подключения. */
129
156
  readonly cache: {
130
157
  clear(): void;
131
158
  stats(): CacheStats;
132
159
  };
133
- /** Начать путь с готового узла: db.entity(row | цепочка). */
160
+ /** Начать путь с готового узла: `db.entity(row | цепочка)`. */
134
161
  entity(x: Chain | Row): Chain;
135
162
  /**
136
- * Тот же пул от имени аккаунта (letopis.account: имперсонацию проверяет база — право сервиса
137
- * auth.impersonate), в арендаторе tenant (собственный аккаунта или по членству) и с явным владельцем
138
- * новых строк: await db.as(account, { tenant, owner }). account = null — тот же актор.
163
+ * Подключение на том же пуле от имени другого аккаунта: запросы идут с его правами и в его арендаторе. Журнал
164
+ * записывает автором версий этот аккаунт, а исполнителем (`agent`) — аккаунт сессии, например сервис. Нужно право
165
+ * `auth.impersonate`.
139
166
  */
140
167
  as(account: string | {
141
168
  id: string;
@@ -143,50 +170,52 @@ export interface DbCore {
143
170
  owner?: string;
144
171
  tenant?: string;
145
172
  }): Promise<Db>;
146
- /** Явная транзакция read committed: те же цепочки, commit(), rollback(), lock(), forUpdate(). */
147
- /** Явная транзакция; reason — причина изменения для всех операций блока (план 1.1). */
173
+ /** Явная транзакция `read committed`: те же цепочки, `commit()`, `rollback()`, `lock()`, `forUpdate()`. */
174
+ /** Явная транзакция; `reason` — причина изменения для всех операций блока (план 1.1). */
148
175
  begin(opts?: {
149
176
  reason?: string;
150
177
  }): Promise<DbTx>;
151
178
  /**
152
- * Загрузчик (§2.6): строки любых классов — проверки в коде, COPY от роли владельца, один запрос по
153
- * целям вне загрузки. Только на администраторском подключении (иначе admin_required).
179
+ * Массовая загрузка строк любых классов одной транзакцией (или порциями `commitEvery`) — быстрее записи цепочками.
180
+ * Строки проверяются по тому же описанию класса, что и при обычной записи (данные, ссылки, ключ), уходят в базу
181
+ * командой `COPY`, а ссылки на объекты вне загрузки сверяет один запрос в конце; права проверяются только при загрузке
182
+ * от имени пользователя (опция `as`). Работает только на администраторском подключении, иначе `admin_required`.
154
183
  */
155
184
  load(input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
156
185
  /**
157
- * Проектор из журнала (план 1.1, §2.4): строит строки производных классов (derivedBy: имя) из изменений
158
- * классов from; run() — догнать журнал и следовать за ним, rebuild() — перестроить с текущего состояния.
159
- * Только на администраторском подключении (иначе admin_required).
186
+ * Проектор из журнала (план 1.1, §2.4): строит строки производных классов (`derivedBy: имя`) из изменений
187
+ * классов `from`; `run()` — догнать журнал и следовать за ним, `rebuild()` — перестроить с текущего состояния.
188
+ * Только на администраторском подключении (иначе `admin_required`).
160
189
  */
161
190
  projector(name: string, opts: ProjectorOptions): Projector;
162
191
  /**
163
192
  * Ремонт агрегатов в базе (план 1.1, §2.4): поля класса (с потомками) во всех арендаторах — заново из живых строк
164
193
  * источников, под исключительными блокировками; версия — только у исправленных. Возвращает их число. Только на
165
- * администраторском подключении (иначе admin_required).
194
+ * администраторском подключении (иначе `admin_required`).
166
195
  */
167
196
  rebuildAggregates(cls: string): Promise<number>;
168
197
  /**
169
- * Именованная очередь планов фасада: batch.Класс(…).create(…); batch.run() — одной транзакцией. У корня,
170
- * у каждого db.as() и у каждой db.begin() очереди свои: план исполняется от имени фасада, который его поставил.
198
+ * Именованная очередь планов фасада: `batch.Класс(…).create(…)`; `batch.run()` — одной транзакцией. У корня,
199
+ * у каждого `db.as()` и у каждой `db.begin()` очереди свои: план исполняется от имени фасада, который его поставил.
171
200
  */
172
201
  batch(name: string, opts?: {
173
202
  reason?: string;
174
203
  }): Batch;
175
- /** Advisory-блокировка до конца транзакции — только в db.begin() (иначе tx_required). */
204
+ /** Advisory-блокировка до конца транзакции — только в `db.begin()` (иначе `tx_required`). */
176
205
  lock(...keys: (string | number)[]): Promise<void>;
177
- /** Зафиксировать транзакцию tr (то же, что tr.commit()). */
206
+ /** Зафиксировать транзакцию `tr` (то же, что `tr.commit()`). */
178
207
  commit(tr?: DbTx): Promise<void>;
179
- /** Откатить транзакцию tr (то же, что tr.rollback()). */
208
+ /** Откатить транзакцию `tr` (то же, что `tr.rollback()`). */
180
209
  rollback(tr?: DbTx): Promise<void>;
181
210
  close(): Promise<void>;
182
211
  }
183
- /** Подключение: служебные методы и классы как стартовые шаги цепочек — db.Класс(фильтр). */
212
+ /** Подключение: служебные методы и классы как стартовые шаги цепочек — `db.Класс(фильтр)`. */
184
213
  export type Db = DbCore & {
185
214
  [className: string]: StepFn;
186
215
  };
187
- /** Явная транзакция db.begin(): тот же API; ошибка любого запроса её ломает (§2.5). */
216
+ /** Явная транзакция `db.begin()`: тот же API; ошибка любого запроса её ломает (§2.5). */
188
217
  export type DbTx = Db;
189
- /** Подключение с типами шагов, строк и фильтров по моделям: connect({ models }). */
218
+ /** Подключение с типами шагов, строк и фильтров по моделям: `connect({ models })`. */
190
219
  export type TypedDb<M extends readonly AnyModelLike[]> = DbCore & StepsOf<M> & {
191
220
  [className: string]: StepFn;
192
221
  };
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import { ACL_CLASSES, ResultCache } from './cache.js';
23
23
  import { Subscription } from './watch.js';
24
24
  import { Projector } from './project.js';
25
25
  import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
26
- import { describe as describeModel, maintain as runMaintain, rebuildAggregates as runRebuildAggregates, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
26
+ import { describe as describeModel, maintain as runMaintain, rebuildAggregates as runRebuildAggregates, rekeyClass as runRekeyClass, reset as runReset, MAINTAIN_STOP, parseInterval, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
27
27
  import { stampDrift } from './up.js';
28
28
  export { DDL_REVISION } from './types.js';
29
29
  export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
@@ -233,6 +233,8 @@ export async function connect(opts) {
233
233
  for (const k of Object.keys(opts))
234
234
  if (k in REMOVED_OPTIONS)
235
235
  throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
236
+ // интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
237
+ const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
236
238
  const own = !opts.sql;
237
239
  // timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
238
240
  const sql = (opts.sql ?? postgres(opts.dsn, {
@@ -268,7 +270,11 @@ export async function connect(opts) {
268
270
  throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
269
271
  if (rev < DDL_REVISION)
270
272
  console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
271
- else if (rev === DDL_REVISION) {
273
+ // схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
274
+ // этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
275
+ else if (rev > DDL_REVISION)
276
+ console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev} новее библиотеки (она знает ${DDL_REVISION}) — обновите пакет letopis`);
277
+ else {
272
278
  // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
273
279
  const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
274
280
  if (drift)
@@ -472,7 +478,8 @@ export async function connect(opts) {
472
478
  signals,
473
479
  listen,
474
480
  async close() {
475
- stopMaintain?.();
481
+ // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
482
+ await stopMaintain?.();
476
483
  resync.stop();
477
484
  startReload.stop();
478
485
  if (stamper)
@@ -484,8 +491,8 @@ export async function connect(opts) {
484
491
  },
485
492
  };
486
493
  const db = makeDb(shared, { txc: ctx, tenant: () => root.tenant, registry: () => root.registry, root: true }, poolExecutor(ctx));
487
- if (opts.maintain !== undefined)
488
- stopMaintain = scheduleMaintain(() => db.maintain(), opts.maintain);
494
+ if (maintainEvery !== undefined)
495
+ stopMaintain = scheduleMaintain((stopped) => db.maintain({ [MAINTAIN_STOP]: stopped }), maintainEvery);
489
496
  return db;
490
497
  }
491
498
  catch (e) {
package/dist/load.d.ts CHANGED
@@ -12,7 +12,10 @@ export interface LoadOptions {
12
12
  batch?: number;
13
13
  /** Фиксировать порциями примерно по N строк (порция — классы в порядке зависимостей, цикл — целиком). */
14
14
  commitEvery?: number;
15
- /** Режим истории (импорт): строки — версии с $rev, $at и $op; пишутся волнами. */
15
+ /**
16
+ * Режим истории (импорт): каждая строка — версия объекта с номером `$rev`, временем `$at` и видом операции `$op`; так
17
+ * переносят историю объектов из другой системы.
18
+ */
16
19
  withHistory?: boolean;
17
20
  /** В конце перепроверить загруженные строки проверкой базы. */
18
21
  verify?: boolean;
@@ -20,7 +23,10 @@ export interface LoadOptions {
20
23
  class?: string;
21
24
  /** Арендатор строк (по умолчанию — арендатор сессии). */
22
25
  tenant?: string;
23
- /** Агент версий (по умолчанию «letopis load»; импорт — «letopis import»). */
26
+ /**
27
+ * Исполнитель записи — строка, которую журнал сохранит в поле `agent` загруженных версий рядом с автором (по умолчанию
28
+ * «letopis load»; импорт — «letopis import»).
29
+ */
24
30
  agent?: string;
25
31
  /**
26
32
  * Загрузка от имени пользователя (§2.6, п. 10): автор версий — он; арендатор (по умолчанию —
@@ -128,7 +134,8 @@ export declare function load(env: LoadEnv, input: LoadInput, opts?: LoadOptions)
128
134
  * Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
129
135
  * проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
130
136
  * журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
131
- * pg_get_triggerdef его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
137
+ * pg_get_triggerdef его не передаёт) сохраняются в памяти; внешний ключ secret.account снимается до TRUNCATE и
138
+ * возвращается после первичного ключа entity; TRUNCATE; триггеры, ограничения и индексы
132
139
  * снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
133
140
  * переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
134
141
  * value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
package/dist/load.js CHANGED
@@ -924,7 +924,7 @@ export async function load(env, input, opts = {}) {
924
924
  lg as (
925
925
  insert into ${S}.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
926
926
  select id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash from up
927
- where ${S}.history_on(up.class, up.tenant))
927
+ where ${S}.history_on(up.class, up.tenant) order by up.id)
928
928
  select up.id, up.class from up`, [updates.map((u) => ({ id: u.id, data: u.data, links: u.links, tags: u.tags })), ts, author, agent]);
929
929
  report.updated += res.length;
930
930
  report.skipped += updates.length - res.length;
@@ -1020,12 +1020,17 @@ export async function load(env, input, opts = {}) {
1020
1020
  await copyLines(conn, `copy ${S}.entity (${ENTITY_COLS}) from stdin${ini?.freeze ? ' with (freeze)' : ''}`, ent);
1021
1021
  }
1022
1022
  }
1023
- /** Определения из каталога: ограничения (первичный ключ, уникальные), прочие индексы, триггеры с режимом, колонки. */
1023
+ /**
1024
+ * Определения из каталога: ограничения (первичный ключ, уникальные), внешние ключи других таблиц на entity и log
1025
+ * (secret.account, ревизия 7), прочие индексы, триггеры с режимом, колонки.
1026
+ */
1024
1027
  async function catalogOf(conn, S) {
1025
1028
  const rels = [`${S}.entity`, `${S}.log`];
1026
1029
  const sel = `(select x::regclass from unnest($1::text[]) x)`;
1027
1030
  const constraints = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
1028
1031
  from pg_constraint where conrelid in ${sel} and contype in ('p', 'u') order by conrelid, conname`, [rels]);
1032
+ const foreign = await conn.unsafe(`select conname as name, conrelid::regclass::text as tbl, pg_get_constraintdef(oid) as def
1033
+ from pg_constraint where confrelid in ${sel} and contype = 'f' order by conrelid, conname`, [rels]);
1029
1034
  const indexes = await conn.unsafe(`select i.indexrelid::regclass::text as name, pg_get_indexdef(i.indexrelid) as def
1030
1035
  from pg_index i where i.indrelid in ${sel}
1031
1036
  and not exists (select 1 from pg_constraint c where c.conindid = i.indexrelid and c.conrelid = i.indrelid and c.contype in ('p', 'u', 'x'))
@@ -1037,6 +1042,7 @@ async function catalogOf(conn, S) {
1037
1042
  where attrelid = $1::regclass and attnum > 0 and not attisdropped and attgenerated = ''`, [rel]))[0].c;
1038
1043
  return {
1039
1044
  constraints: constraints,
1045
+ foreign: foreign,
1040
1046
  indexes: indexes,
1041
1047
  triggers: triggers,
1042
1048
  entityCols: await cols(rels[0]),
@@ -1047,7 +1053,8 @@ async function catalogOf(conn, S) {
1047
1053
  * Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
1048
1054
  * проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
1049
1055
  * журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
1050
- * pg_get_triggerdef его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
1056
+ * pg_get_triggerdef его не передаёт) сохраняются в памяти; внешний ключ secret.account снимается до TRUNCATE и
1057
+ * возвращается после первичного ключа entity; TRUNCATE; триггеры, ограничения и индексы
1051
1058
  * снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
1052
1059
  * переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
1053
1060
  * value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
@@ -1081,7 +1088,11 @@ export async function initialSession(env, fn, tune = {}) {
1081
1088
  const triggers = tune.keepTriggers ? [] : cat.triggers;
1082
1089
  const keys = tune.keepKeys ? [] : cat.constraints;
1083
1090
  const indexes = cat.indexes.filter((i) => !(tune.keepGin && /\busing gin\b/i.test(i.def)));
1084
- // 4. TRUNCATE; триггеры, ограничения и индексы — снять
1091
+ // 4. TRUNCATE; триггеры, ограничения и индексы — снять. Внешний ключ secret.account на entity (ревизия 7) — до
1092
+ // TRUNCATE при любом keepKeys: таблицу, на которую ссылается внешний ключ, TRUNCATE без неё не очищает, а secret
1093
+ // (ключ установки, сессии, креды) заливка не трогает
1094
+ for (const f of cat.foreign)
1095
+ await q(`alter table ${f.tbl} drop constraint ${ident(f.name)}`);
1085
1096
  await q(`truncate ${S}.entity, ${S}.log`);
1086
1097
  for (const t of triggers)
1087
1098
  await q(`drop trigger ${ident(t.name)} on ${t.tbl}`);
@@ -1107,6 +1118,10 @@ export async function initialSession(env, fn, tune = {}) {
1107
1118
  least(current_setting('max_parallel_workers')::int, 4))::text, true)`);
1108
1119
  for (const c of keys)
1109
1120
  await q(`alter table ${c.tbl} add constraint ${ident(c.name)} ${c.def}`);
1121
+ // внешние ключи на entity — после её первичного ключа: системные строки вернулись с прежними id, строки secret
1122
+ // ссылаются на них
1123
+ for (const f of cat.foreign)
1124
+ await q(`alter table ${f.tbl} add constraint ${ident(f.name)} ${f.def}`);
1110
1125
  for (const i of indexes)
1111
1126
  await q(i.def);
1112
1127
  // 8. триггеры — с прежним режимом включения (ENABLE ALWAYS работает и при session_replication_role = replica)
package/dist/mcp.d.ts CHANGED
@@ -3,26 +3,35 @@ import type { Db } from './index.js';
3
3
  export interface McpOptions {
4
4
  /** Инструмент letopis_write (create, update, upsert, delete); по умолчанию сервер только читает. */
5
5
  write?: boolean;
6
- /** Строк в ответе не больше (по умолчанию 100); дальше — курсор. */
6
+ /**
7
+ * Предел строк в одном ответе (по умолчанию 100). Остальные строки `letopis_query` отдаёт на следующих страницах по
8
+ * курсору, а версии, группы `countBy` и изменения `diff` и `blame` сверх предела обрезаются с пометкой `more` или
9
+ * `truncated`.
10
+ */
7
11
  maxRows?: number;
8
- /** Байт JSON строк в ответе не больше (по умолчанию 50 000); дальше — курсор. */
12
+ /**
13
+ * Предел размера строк одного ответа в байтах JSON (по умолчанию 50 000); что не поместилось — так же, как сверх
14
+ * `maxRows`. Описание модели больше предела `letopis_model` отдаёт кратким.
15
+ */
9
16
  maxBytes?: number;
10
17
  /**
11
- * letopis_write пишет и классы прав — rule, Resource, member, Account, Class и их потомков (правила, членства,
12
- * описания классов). По умолчанию — отказ acl_denied: агент под инъекцией в данных выдал бы постороннему членство или
13
- * правило без подтверждения человека (ревью безопасности №2, О5).
18
+ * Разрешить `letopis_write` писать и классы прав — `rule`, `Resource`, `member`, `Account`, `Class` и их потомков
19
+ * (правила, ресурсы, членства, аккаунты, описания классов). По умолчанию — отказ `acl_denied`: агент, которому в данных
20
+ * подсунули чужие инструкции, мог бы без подтверждения человека выдать постороннему членство или право
21
+ * (ревью безопасности №2, О5).
14
22
  */
15
23
  allowAclWrite?: boolean;
16
24
  /**
17
- * Сессия в арендаторе System — так работает системный администратор: его роль admin даёт права на аккаунты,
18
- * членства, правила и letopis.*. По умолчанию — отказ user_session_required (ревью безопасности №2, О5).
25
+ * Принять сессию в арендаторе System, то есть сессию системного администратора: с `true` агент получает его права на
26
+ * аккаунты, членства, правила и `letopis.*`, но писать классы прав по-прежнему может только с `allowAclWrite`. По
27
+ * умолчанию такая сессия — отказ `user_session_required` (ревью безопасности №2, О5).
19
28
  */
20
29
  allowSystemAdmin?: boolean;
21
30
  }
22
31
  /**
23
- * Сервер MCP для одного пользователя letopis: инструменты letopis_model, letopis_query, letopis_history и при
24
- * { write: true } — letopis_write. Подключение — сессия пользователя (connect({ token })) или фасад db.as(пользователь).
25
- * Транспорт выбирает приложение: server.connect(транспорт), serveStdio(() => createMcpServer(db)) или
26
- * createMcpHandler(() => createMcpServer(db)) SDK — со своей авторизацией.
32
+ * Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
33
+ * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
34
+ * `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
35
+ * или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
27
36
  */
28
37
  export declare function createMcpServer(db: Db, opts?: McpOptions): Promise<McpServer>;
package/dist/mcp.js CHANGED
@@ -782,10 +782,10 @@ async function checkCredentials(db, o) {
782
782
  return { account: c.account, tenant: String(c.tenant ?? '') };
783
783
  }
784
784
  /**
785
- * Сервер MCP для одного пользователя letopis: инструменты letopis_model, letopis_query, letopis_history и при
786
- * { write: true } — letopis_write. Подключение — сессия пользователя (connect({ token })) или фасад db.as(пользователь).
787
- * Транспорт выбирает приложение: server.connect(транспорт), serveStdio(() => createMcpServer(db)) или
788
- * createMcpHandler(() => createMcpServer(db)) SDK — со своей авторизацией.
785
+ * Сервер MCP для одного пользователя letopis: инструменты `letopis_model`, `letopis_query`, `letopis_history`, а с
786
+ * `{ write: true }` — ещё `letopis_write`. Подключение — сессия пользователя (`connect({ token })`) или фасад
787
+ * `db.as(пользователь)`. Транспорт выбирает приложение: `server.connect(транспорт)`, `serveStdio(() => createMcpServer(db))`
788
+ * или обработчик HTTP `createMcpHandler(() => createMcpServer(db))` из SDK; токен запроса HTTP проверяет приложение.
789
789
  */
790
790
  export async function createMcpServer(db, opts = {}) {
791
791
  const o = limitsOf(opts);
package/dist/model.d.ts CHANGED
@@ -191,7 +191,10 @@ type ShapeOf<F> = F extends z.ZodObject<infer S> ? S : F extends z.ZodRawShape ?
191
191
  type AliasOf<D> = D extends {
192
192
  alias: infer A extends string;
193
193
  } ? A : string;
194
- /** Хаб — объект (мастер, услуга). */
194
+ /**
195
+ * Описывает класс объектов (хаб): поля, ключ, концы и свойства класса. Класс-связь объектов со своими данными
196
+ * описывает `link()`.
197
+ */
195
198
  export declare function hub<N extends string, S extends z.ZodRawShape>(name: N, shape: S, opts: Omit<ClassDef, 'fields'>): Model<N, z.output<z.ZodObject<S>>, Record<never, never>>;
196
199
  export declare function hub<N extends string, const D extends ClassDef<z.ZodRawShape, EndsInput, AnyModel | undefined>>(name: N, def?: D): Model<N, DataOf<D['extends'], ShapeOf<D['fields']>>, AllLinks<D['extends'], D['ends'] extends EndsInput ? D['ends'] : Record<never, never>>, AliasOf<D>>;
197
200
  /** Связь — отношение между объектами со своими данными (бронь, навык). */
package/dist/ops.d.ts CHANGED
@@ -5,39 +5,79 @@
5
5
  */
6
6
  import { type Op, type Scalar } from './types.js';
7
7
  export declare const isOp: (v: unknown) => v is Op;
8
- /** ≠ (IS DISTINCT FROM) */
8
+ /**
9
+ * Не равно: `db.Order({ status: ne('cancelled') })`. Подходят и строки, где поля нет или в нём `null`, а `ne(null)` —
10
+ * строки, где у поля есть значение.
11
+ */
9
12
  export declare const ne: (v: Scalar) => Op;
10
- /** > */
13
+ /**
14
+ * Больше: `db.Product({ price: gt(1000) })`. Сравнение — по типу поля из описания класса (числа — как числа, даты — как
15
+ * даты), поле без описания сравнивается как текст; строки без поля не подходят.
16
+ */
11
17
  export declare const gt: (v: Scalar) => Op;
12
- /** ≥ */
18
+ /** Больше или равно: `db.Product({ price: gte(500) })`. Сравнение — как у `gt()`. */
13
19
  export declare const gte: (v: Scalar) => Op;
14
- /** < */
20
+ /** Меньше: `db.Product({ price: lt(1000) })`. Сравнение — как у `gt()`. */
15
21
  export declare const lt: (v: Scalar) => Op;
16
- /** ≤ */
22
+ /** Меньше или равно: `db.Product({ price: lte(900) })`. Сравнение — как у `gt()`. */
17
23
  export declare const lte: (v: Scalar) => Op;
18
- /** a ≤ x ≤ b */
24
+ /**
25
+ * От `a` до `b` включительно: `db.Product({ price: between(500, 3000) })`. Сравнение — как у `gt()`; если `a` больше
26
+ * `b`, не подходит ни одна строка.
27
+ */
19
28
  export declare const between: (a: Scalar, b: Scalar) => Op;
20
- /** значение из списка */
29
+ /**
30
+ * Одно из значений списка: `db.Product({ sku: inList(['K-1', 'M-1']) })`. Сравнение — по типу поля, как у `gt()`;
31
+ * пустой список не находит ничего, а `null` в списке не находит строк без значения — для них есть `isNull()`.
32
+ */
21
33
  export declare const inList: (vs: Scalar[]) => Op;
22
- /** LIKE (шаблон с % и _) */
34
+ /**
35
+ * Текст по шаблону с учётом регистра: `%` — любые символы, в том числе ни одного, `_` — ровно один символ;
36
+ * `db.Product({ title: like('%чай%') })`. Поле берётся как текст; буквальные `%` и `_` пишут с обратной косой чертой:
37
+ * `like('100\\%')`.
38
+ */
23
39
  export declare const like: (s: string) => Op;
24
- /** ILIKE (без регистра) */
40
+ /** Как `like()`, но без учёта регистра: `db.Product({ title: ilike('%чай%') })`. */
25
41
  export declare const ilike: (s: string) => Op;
26
- /** начинается с */
42
+ /**
43
+ * Текст начинается с `s`, с учётом регистра: `db.Product({ sku: starts('T-') })`. Это `like(s + '%')`: `%`, `_` и
44
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
45
+ */
27
46
  export declare const starts: (s: string) => Op;
28
- /** заканчивается на */
47
+ /**
48
+ * Текст заканчивается на `s`, с учётом регистра: `db.Product({ sku: ends('-1') })`. Это `like('%' + s)`: `%`, `_` и
49
+ * обратная косая черта внутри `s` не экранируются и работают как в шаблоне `like()`.
50
+ */
29
51
  export declare const ends: (s: string) => Op;
30
- /** массив-поле содержит значение */
52
+ /**
53
+ * Поле-массив содержит элемент `v`: `db.Product({ labels: has('хит') })`. Тип важен: `has(5)` найдёт число 5, но не
54
+ * строку `'5'`.
55
+ */
31
56
  export declare const has: (v: Scalar) => Op;
32
- /** массив-поле содержит хотя бы одно из */
57
+ /**
58
+ * Поле-массив содержит хотя бы один элемент из списка: `db.Product({ labels: hasAny(['хит', 'акция']) })`. Находит
59
+ * только строковые элементы; пустой список не находит ничего.
60
+ */
33
61
  export declare const hasAny: (vs: Scalar[]) => Op;
34
- /** массив-поле содержит все */
62
+ /**
63
+ * Поле-массив содержит все элементы списка, в любом порядке: `db.Product({ labels: hasAll(['хит', 'стекло']) })`. Тип
64
+ * элементов важен, как у `has()`; пустой список подходит любой строке, даже без этого поля.
65
+ */
35
66
  export declare const hasAll: (vs: Scalar[]) => Op;
36
- /** поле присутствует (true) или отсутствует (false) */
67
+ /**
68
+ * Поле есть в данных (`true`, по умолчанию) или его нет (`false`); значение не важно — поле с `null` тоже есть:
69
+ * `db.Product({ labels: exists(false) })` — товары без поля `labels`.
70
+ */
37
71
  export declare const exists: (yes?: boolean) => Op;
38
- /** поле null или отсутствует */
72
+ /** В поле `null` или поля нет совсем: `db.Product({ specs: isNull() })`. Отличить одно от другого помогает `exists()`. */
39
73
  export declare const isNull: () => Op;
40
- /** НЕ-условие по полю: not(ilike('%тест%')); скаляр — «не равно» */
74
+ /**
75
+ * Отрицание оператора: `db.Product({ price: not(gte(1000)) })` — цена меньше 1000 или поля нет. Простое значение вместо
76
+ * оператора значит «не равно», как `ne()`: `db.Order({ status: not('cancelled') })`.
77
+ */
41
78
  export declare const not: (v: Scalar | Op) => Op;
42
- /** ИЛИ на уровне фильтра шага: t.booking(or({ notes: 'a' }, { notes: 'b' })) */
79
+ /**
80
+ * Хотя бы один из фильтров: `db.Product(or({ sku: 'K-1' }, { price: lt(500) }))` — товар K-1 или дешевле 500. Ставится
81
+ * вместо всего фильтра шага, а не значением поля; условия внутри одного фильтра соединяются через «и».
82
+ */
43
83
  export declare const or: (...filters: Record<string, unknown>[]) => Op;