letopis 1.4.0 → 1.4.2

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 (62) hide show
  1. package/AGENT-CHEATSHEET.en.md +15 -7
  2. package/AGENT-CHEATSHEET.md +19 -11
  3. package/CHANGELOG.md +299 -1
  4. package/README.en.md +180 -62
  5. package/README.md +165 -55
  6. package/dist/acl.d.ts +11 -5
  7. package/dist/admin.d.ts +30 -14
  8. package/dist/admin.js +53 -18
  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/cache.js +3 -2
  13. package/dist/chain.d.ts +92 -15
  14. package/dist/chain.js +56 -7
  15. package/dist/cli.js +16 -2
  16. package/dist/errors.d.ts +2 -2
  17. package/dist/errors.js +5 -5
  18. package/dist/import.js +7 -0
  19. package/dist/index.d.ts +73 -45
  20. package/dist/index.js +69 -14
  21. package/dist/internal.d.ts +7 -0
  22. package/dist/internal.js +5 -0
  23. package/dist/load.d.ts +10 -3
  24. package/dist/load.js +39 -11
  25. package/dist/mcp.d.ts +20 -11
  26. package/dist/mcp.js +8 -6
  27. package/dist/model.d.ts +4 -1
  28. package/dist/model.js +7 -1
  29. package/dist/ops.d.ts +58 -18
  30. package/dist/ops.js +58 -18
  31. package/dist/project.d.ts +39 -11
  32. package/dist/project.js +75 -15
  33. package/dist/registry.d.ts +4 -1
  34. package/dist/registry.js +4 -1
  35. package/dist/signals.d.ts +4 -4
  36. package/dist/signals.js +4 -4
  37. package/dist/sql.d.ts +8 -4
  38. package/dist/sql.js +110 -25
  39. package/dist/sync.js +11 -4
  40. package/dist/tx.d.ts +15 -0
  41. package/dist/tx.js +22 -4
  42. package/dist/types.d.ts +21 -7
  43. package/dist/types.js +13 -6
  44. package/dist/up.d.ts +48 -25
  45. package/dist/up.js +94 -41
  46. package/dist/validate.d.ts +9 -2
  47. package/dist/validate.js +19 -3
  48. package/dist/watch.d.ts +29 -5
  49. package/dist/watch.js +62 -13
  50. package/dist/write.d.ts +3 -2
  51. package/dist/write.js +52 -19
  52. package/package.json +8 -4
  53. package/sql/10-core.sql +6 -1
  54. package/sql/60-write.sql +47 -18
  55. package/sql/70-read.sql +13 -7
  56. package/sql/75-history.sql +106 -28
  57. package/sql/80-auth.sql +41 -3
  58. package/sql/88-derived.sql +3 -1
  59. package/sql/90-time.sql +118 -30
  60. package/sql/95-seed.system.sql +6 -3
  61. package/sql/99-revision.sql +1 -1
  62. package/sql/migrate/0007-secret-account-fk.sql +12 -0
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,51 @@ 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()`; `reason` — причина изменения для всех операций блока (план 1.1). */
148
174
  begin(opts?: {
149
175
  reason?: string;
150
176
  }): Promise<DbTx>;
151
177
  /**
152
- * Загрузчик (§2.6): строки любых классов — проверки в коде, COPY от роли владельца, один запрос по
153
- * целям вне загрузки. Только на администраторском подключении (иначе admin_required).
178
+ * Массовая загрузка строк любых классов одной транзакцией (или порциями `commitEvery`) — быстрее записи цепочками.
179
+ * Строки проверяются по тому же описанию класса, что и при обычной записи (данные, ссылки, ключ), уходят в базу
180
+ * командой `COPY`, а ссылки на объекты вне загрузки сверяет один запрос в конце; права проверяются только при загрузке
181
+ * от имени пользователя (опция `as`). Работает только на администраторском подключении, иначе `admin_required`.
154
182
  */
155
183
  load(input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
156
184
  /**
157
- * Проектор из журнала (план 1.1, §2.4): строит строки производных классов (derivedBy: имя) из изменений
158
- * классов from; run() — догнать журнал и следовать за ним, rebuild() — перестроить с текущего состояния.
159
- * Только на администраторском подключении (иначе admin_required).
185
+ * Проектор из журнала (план 1.1, §2.4): строит строки производных классов (`derivedBy: имя`) из изменений
186
+ * классов `from`; `run()` — догнать журнал и следовать за ним, `rebuild()` — перестроить с текущего состояния.
187
+ * Только на администраторском подключении (иначе `admin_required`).
160
188
  */
161
189
  projector(name: string, opts: ProjectorOptions): Projector;
162
190
  /**
163
191
  * Ремонт агрегатов в базе (план 1.1, §2.4): поля класса (с потомками) во всех арендаторах — заново из живых строк
164
192
  * источников, под исключительными блокировками; версия — только у исправленных. Возвращает их число. Только на
165
- * администраторском подключении (иначе admin_required).
193
+ * администраторском подключении (иначе `admin_required`).
166
194
  */
167
195
  rebuildAggregates(cls: string): Promise<number>;
168
196
  /**
169
- * Именованная очередь планов фасада: batch.Класс(…).create(…); batch.run() — одной транзакцией. У корня,
170
- * у каждого db.as() и у каждой db.begin() очереди свои: план исполняется от имени фасада, который его поставил.
197
+ * Именованная очередь планов фасада: `batch.Класс(…).create(…)`; `batch.run()` — одной транзакцией. У корня,
198
+ * у каждого `db.as()` и у каждой `db.begin()` очереди свои: план исполняется от имени фасада, который его поставил.
171
199
  */
172
200
  batch(name: string, opts?: {
173
201
  reason?: string;
174
202
  }): Batch;
175
- /** Advisory-блокировка до конца транзакции — только в db.begin() (иначе tx_required). */
203
+ /** Advisory-блокировка до конца транзакции — только в `db.begin()` (иначе `tx_required`). */
176
204
  lock(...keys: (string | number)[]): Promise<void>;
177
- /** Зафиксировать транзакцию tr (то же, что tr.commit()). */
205
+ /** Зафиксировать транзакцию `tr` (то же, что `tr.commit()`). */
178
206
  commit(tr?: DbTx): Promise<void>;
179
- /** Откатить транзакцию tr (то же, что tr.rollback()). */
207
+ /** Откатить транзакцию `tr` (то же, что `tr.rollback()`). */
180
208
  rollback(tr?: DbTx): Promise<void>;
181
209
  close(): Promise<void>;
182
210
  }
183
- /** Подключение: служебные методы и классы как стартовые шаги цепочек — db.Класс(фильтр). */
211
+ /** Подключение: служебные методы и классы как стартовые шаги цепочек — `db.Класс(фильтр)`. */
184
212
  export type Db = DbCore & {
185
213
  [className: string]: StepFn;
186
214
  };
187
- /** Явная транзакция db.begin(): тот же API; ошибка любого запроса её ломает (§2.5). */
215
+ /** Явная транзакция `db.begin()`: тот же API; ошибка любого запроса её ломает (§2.5). */
188
216
  export type DbTx = Db;
189
- /** Подключение с типами шагов, строк и фильтров по моделям: connect({ models }). */
217
+ /** Подключение с типами шагов, строк и фильтров по моделям: `connect({ models })`. */
190
218
  export type TypedDb<M extends readonly AnyModelLike[]> = DbCore & StepsOf<M> & {
191
219
  [className: string]: StepFn;
192
220
  };
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { LetopisError, fromDbError, removed } from './errors.js';
12
12
  import { Registry } from './registry.js';
13
13
  import { AclPasses, ExplicitTx, Notifier, poolExecutor, runTx } from './tx.js';
14
14
  import { checkReason } from './write.js';
15
- import { registerDb } from './internal.js';
15
+ import { limits, registerDb } from './internal.js';
16
16
  import { makeAuth } from './auth.js';
17
17
  import { makeAcl } from './acl.js';
18
18
  import { idOf as idOfClass } from './uuid.js';
@@ -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';
@@ -120,13 +120,23 @@ function makeDb(sh, scope, exec, owner, tx) {
120
120
  throw removed('watch(cb) и watch(класс, cb)', 'watch({ from }) — подписка по курсору');
121
121
  const reg = scope.registry();
122
122
  const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
123
- return new Subscription({
124
- S: sh.S, exec: poolExecutor(txc), classes,
123
+ return sh.track(new Subscription({
124
+ S: sh.S, exec: poolExecutor(txc), classes, onClose: (s) => void sh.subs.delete(s),
125
+ // не бросает: класс удалили — список, снятый при watch(); иначе ошибка в общем разборе сигналов остановила бы
126
+ // доставку другим подписчикам
127
+ ...(o.classes ? { classesOf: () => {
128
+ try {
129
+ return [...new Set(o.classes.flatMap((c) => scope.registry().family(c).map((x) => x.name)))];
130
+ }
131
+ catch {
132
+ return classes;
133
+ }
134
+ } } : {}),
125
135
  ...(sh.listen ? {
126
136
  onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
127
137
  tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
128
138
  } : {}),
129
- }, o);
139
+ }, o));
130
140
  },
131
141
  cache: {
132
142
  clear: () => sh.cache?.clear(),
@@ -152,7 +162,7 @@ function makeDb(sh, scope, exec, owner, tx) {
152
162
  const tenant = r.tenant;
153
163
  const registry = await sh.registryFor(tenant, scoped);
154
164
  if (registry)
155
- return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
165
+ return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => sh.registryOf(tenant, registry, scoped), root: false }, poolExecutor(scoped), nextOwner);
156
166
  if (attempt === 3)
157
167
  throw new LetopisError('no_session', 'db.as(): контекст сессии меняется — реестр арендатора не загружен');
158
168
  [r] = await runTx(scoped, (q) => q.unsafe(`select account, tenant from ${sh.S}.ctx()`), { read: true });
@@ -233,6 +243,8 @@ export async function connect(opts) {
233
243
  for (const k of Object.keys(opts))
234
244
  if (k in REMOVED_OPTIONS)
235
245
  throw removed(`опция connect({ ${k} })`, REMOVED_OPTIONS[k]);
246
+ // интервал обслуживания — до пула и таймеров: неверный не оставляет за собой открытых соединений
247
+ const maintainEvery = opts.maintain === undefined ? undefined : parseInterval(opts.maintain);
236
248
  const own = !opts.sql;
237
249
  // timestamptz — строкой: Date в JS режет микросекунды, а на них держатся asOf и курсоры по времени
238
250
  const sql = (opts.sql ?? postgres(opts.dsn, {
@@ -244,6 +256,10 @@ export async function connect(opts) {
244
256
  const owner = opts.owner ?? 'letopis_owner';
245
257
  try {
246
258
  // роль, которая обходит RLS, видела бы данные всех арендаторов (§2.9)
259
+ // роли владельца нет (опечатка в owner) — понятная ошибка, а не сырая 42704 PostgreSQL (план 2.0, 7.5 №3)
260
+ const [known] = await sql `select exists (select 1 from pg_roles where rolname = ${owner}) as ok`;
261
+ if (!known.ok)
262
+ throw new LetopisError('invalid_data', `connect: роли владельца ${owner} в базе нет — проверьте опцию owner (по умолчанию letopis_owner)`);
247
263
  const [role] = await sql `select r.rolsuper, r.rolbypassrls,
248
264
  pg_has_role(current_user, ${owner}, 'USAGE') as inherits, pg_has_role(current_user, ${owner}, 'SET') as can_set
249
265
  from pg_roles r where r.rolname = current_user`;
@@ -268,7 +284,11 @@ export async function connect(opts) {
268
284
  throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
269
285
  if (rev < DDL_REVISION)
270
286
  console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
271
- else if (rev === DDL_REVISION) {
287
+ // схему обновил более новый пакет (во время раскатки старые экземпляры ещё работают): подключаемся, но предупреждаем —
288
+ // этот пакет может не знать функций и колонок новой ревизии; up() такую схему не трогает и отказывает
289
+ else if (rev > DDL_REVISION)
290
+ console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev} новее библиотеки (она знает ${DDL_REVISION}) — обновите пакет letopis`);
291
+ else {
272
292
  // ревизия та же, а функции схемы ставила другая версия движка — штамп в комментарии схемы (99-revision.sql)
273
293
  const drift = await stampDrift(opts.schema, String(rv.c ?? ''));
274
294
  if (drift)
@@ -310,8 +330,18 @@ export async function connect(opts) {
310
330
  return null;
311
331
  return q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`);
312
332
  }, { read: true });
313
- // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора
333
+ // реестры db.as() — общие на арендатора; txc — контекст последнего успешного as() этого арендатора. Порядок карты —
334
+ // давность обращения: сверх limits.asRegistries вытесняется самый давний (план 2.0, 7.4 №8 — иначе процесс,
335
+ // который ходит от имени многих арендаторов, копил бы реестры и перечитывал их все на каждый сигнал классов)
314
336
  const others = new Map();
337
+ let latest = null;
338
+ const keep = (tenant, entry) => {
339
+ latest = tenant;
340
+ others.delete(tenant);
341
+ others.set(tenant, entry);
342
+ while (others.size > Math.max(1, limits.asRegistries))
343
+ others.delete(others.keys().next().value);
344
+ };
315
345
  const loading = new Map();
316
346
  // токены классов (режим opaque) — по именам всех реестров подключения
317
347
  const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
@@ -337,7 +367,7 @@ export async function connect(opts) {
337
367
  const registryFor = async (tenant, txc) => {
338
368
  const known = others.get(tenant);
339
369
  if (known) {
340
- known.txc = txc;
370
+ keep(tenant, { registry: known.registry, txc });
341
371
  return known.registry;
342
372
  }
343
373
  // одновременные as() нового арендатора получают один реестр
@@ -349,7 +379,7 @@ export async function connect(opts) {
349
379
  return null;
350
380
  const registry = new Registry();
351
381
  registry.load(rows);
352
- others.set(tenant, { registry, txc });
382
+ keep(tenant, { registry, txc });
353
383
  tokens();
354
384
  return registry;
355
385
  })().finally(() => loading.delete(tenant));
@@ -357,6 +387,20 @@ export async function connect(opts) {
357
387
  }
358
388
  return pending;
359
389
  };
390
+ // фасад db.as() берёт реестр через карту: свежий реестр арендатора, если его перечитал другой as(); вытесненный —
391
+ // обратно в карту (его снова обновляют сигналы) и перечитать фоном: пока он был вне карты, сигналы его не трогали
392
+ const registryOf = (tenant, registry, txc) => {
393
+ const cur = others.get(tenant);
394
+ if (cur) {
395
+ if (latest !== tenant)
396
+ keep(tenant, cur);
397
+ return cur.registry;
398
+ }
399
+ keep(tenant, { registry, txc });
400
+ void loadClasses(txc, tenant).then((rows) => { if (rows)
401
+ registry.load(rows); }).catch(() => { }).finally(tokens);
402
+ return registry;
403
+ };
360
404
  // ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
361
405
  // работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
362
406
  const fetchKey = async () => {
@@ -462,8 +506,9 @@ export async function connect(opts) {
462
506
  stamper.unref?.();
463
507
  }
464
508
  let stopMaintain = null;
509
+ const subs = new Set();
465
510
  const shared = {
466
- sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
511
+ sql, schema: opts.schema, S, notifier, passes, root, registryFor, registryOf, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
467
512
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
468
513
  ownerRole: owner,
469
514
  cache,
@@ -471,8 +516,18 @@ export async function connect(opts) {
471
516
  allTenants: Boolean(bypass),
472
517
  signals,
473
518
  listen,
519
+ subs,
520
+ track: (s) => {
521
+ subs.add(s);
522
+ return s;
523
+ },
474
524
  async close() {
475
- stopMaintain?.();
525
+ // живые подписки закрываются: иначе с чужим пулом (connect({ sql })) они опрашивали бы журнал и после close()
526
+ // (план 2.0, 7.4 №8)
527
+ for (const s of [...subs])
528
+ s.close();
529
+ // идущий maintain() по расписанию доделывает пачку: пул закрывается после неё, а не посреди транзакции
530
+ await stopMaintain?.();
476
531
  resync.stop();
477
532
  startReload.stop();
478
533
  if (stamper)
@@ -484,8 +539,8 @@ export async function connect(opts) {
484
539
  },
485
540
  };
486
541
  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);
542
+ if (maintainEvery !== undefined)
543
+ stopMaintain = scheduleMaintain((stopped) => db.maintain({ [MAINTAIN_STOP]: stopped }), maintainEvery);
489
544
  return db;
490
545
  }
491
546
  catch (e) {
@@ -10,6 +10,13 @@ export interface DbInternals {
10
10
  /** Фасад явной транзакции db.begin(). */
11
11
  readonly explicit: boolean;
12
12
  }
13
+ /**
14
+ * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
15
+ * 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
16
+ */
17
+ export declare const limits: {
18
+ asRegistries: number;
19
+ };
13
20
  /** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
14
21
  export declare function registerDb(db: object, info: DbInternals): void;
15
22
  /** Свойства фасада; undefined — объект не фасад connect() этого пакета. */
package/dist/internal.js CHANGED
@@ -4,6 +4,11 @@
4
4
  * фасад db обходит RLS, не полагаясь на то, что сообщает о себе сам вызывающий.
5
5
  */
6
6
  const facades = new WeakMap();
7
+ /**
8
+ * Пределы подключения, которых нет в опциях: реестров классов db.as() — не больше asRegistries арендаторов (план 2.0,
9
+ * 7.4 №8). Тест меняет предел, чтобы проверить вытеснение без тысячи арендаторов.
10
+ */
11
+ export const limits = { asRegistries: 1000 };
7
12
  /** Запомнить фасад (зовёт makeDb для каждого фасада, который отдаёт наружу). */
8
13
  export function registerDb(db, info) {
9
14
  facades.set(db, info);
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. В конце — ограничения (дубликат найдёт сортировка), индексы,