letopis 1.1.0 → 1.3.0

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.
package/dist/model.d.ts CHANGED
@@ -85,7 +85,33 @@ export interface ClassDef<S extends z.ZodRawShape = z.ZodRawShape, E extends End
85
85
  ttl?: number;
86
86
  };
87
87
  meta?: ClassMeta;
88
+ /** Производный класс (план 1.1, §2.4): строки пишет только проектор с этим именем; наследуется. */
89
+ derivedBy?: string;
90
+ /**
91
+ * Агрегаты в базе (план 1.1, §2.4): поле — сумма поля (`sum`) или число (`count: true`) живых строк класса `of` того
92
+ * же арендатора, чей одиночный конец `via` ведёт на строку; `where` — JSON Schema над данными строки-источника.
93
+ * Поле считает база, приложение его не пишет. Наследуется, потомок заменяет целиком.
94
+ */
95
+ aggregate?: Record<string, AggregateDef>;
88
96
  }
97
+ /** Описание одного агрегатного поля: `sum` или `count: true`; `of` — класс-источник (имя или модель). */
98
+ export type AggregateDef = {
99
+ sum: string;
100
+ count?: never;
101
+ of: string | {
102
+ readonly name: string;
103
+ };
104
+ via: string;
105
+ where?: JsonSchema;
106
+ } | {
107
+ count: true;
108
+ sum?: never;
109
+ of: string | {
110
+ readonly name: string;
111
+ };
112
+ via: string;
113
+ where?: JsonSchema;
114
+ };
89
115
  /** Описание класса в строке `Class` (метасхема `class_metaschema()` в lib/sql/40-class.sql). */
90
116
  export interface ClassDescription {
91
117
  name: string;
@@ -113,6 +139,14 @@ export interface ClassDescription {
113
139
  ttl?: number;
114
140
  };
115
141
  meta?: ClassMeta;
142
+ derivedBy?: string;
143
+ aggregate?: Record<string, {
144
+ sum?: string;
145
+ count?: true;
146
+ of: string;
147
+ via: string;
148
+ where?: JsonSchema;
149
+ }>;
116
150
  }
117
151
  /** Правило refine, которое не попадёт в базу: проверяется только в библиотеке. */
118
152
  export interface LostRule {
package/dist/model.js CHANGED
@@ -145,6 +145,13 @@ function makeModel(kind, name, defArg, optsArg) {
145
145
  throw new LetopisError('invalid_class', `${name}: onDelete unset недопустим для обязательного конца ${role}`);
146
146
  }
147
147
  }
148
+ // агрегаты (план 1.1, §2.4): остальное — роль, поле суммы, цикл — сверяет база по описаниям источников
149
+ for (const [field, a] of Object.entries(def.aggregate ?? {})) {
150
+ if (!(field in shape))
151
+ throw new LetopisError('invalid_class', `${name}: агрегатного поля ${field} нет среди полей класса`);
152
+ if ((a.sum === undefined) === (a.count === undefined))
153
+ throw new LetopisError('invalid_class', `${name}: агрегат ${field} — ровно одно из sum и count: true`);
154
+ }
148
155
  // строгий верхний уровень — как в базе (resolved.schema: additionalProperties false)
149
156
  const schema = z.strictObject(shape);
150
157
  const model = {
@@ -204,6 +211,16 @@ function makeModel(kind, name, defArg, optsArg) {
204
211
  desc.cache = def.cache;
205
212
  if (def.meta)
206
213
  desc.meta = def.meta;
214
+ if (def.derivedBy)
215
+ desc.derivedBy = def.derivedBy;
216
+ if (def.aggregate) {
217
+ desc.aggregate = Object.fromEntries(Object.entries(def.aggregate).map(([field, a]) => [field, {
218
+ ...(a.sum !== undefined ? { sum: a.sum } : { count: true }),
219
+ of: typeof a.of === 'string' ? a.of : a.of.name,
220
+ via: a.via,
221
+ ...(a.where ? { where: a.where } : {}),
222
+ }]));
223
+ }
207
224
  return desc;
208
225
  },
209
226
  lostRules() {
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Проекторы из журнала (план 1.1, §2.4, этап 4а): db.projector(имя, { from, into, version, handle }).
3
+ *
4
+ * Проектор — процесс администратора, как загрузчик: подключение connect({ allowBypassRls: true }) роли,
5
+ * которая может стать владельцем схемы. Каждая пачка — одна транзакция под ролью владельца:
6
+ * строка-замок «схема:projector:имя» (lock_try, до конца транзакции) — писатель один; чекпоинт (строка secret вида
7
+ * PROJECTOR) — for update; события журнала ниже горизонта (watch_read: пачка и горизонт — одним
8
+ * оператором); handle; запись результатов (derived_apply); условный сдвиг чекпоинта (projector_advance).
9
+ * Обрыв посреди пачки откатывает всё, повтор начинает с того же курсора: каждое изменение применено
10
+ * ровно один раз.
11
+ *
12
+ * Арендаторы изолированы (ревью безопасности №2): handle и запись результатов идут группами по арендатору, каждая
13
+ * под своей точкой сохранения. Ошибку базы при записи результатов арендатора (его описания классов, его данные)
14
+ * откатывает только его группа: арендатор выбывает (meta.failed чекпоинта) до rebuild({ tenant }), а остальные
15
+ * идут дальше в той же транзакции. Исключение handle и нарушение границ out — ошибка проектора: пачка
16
+ * откатывается целиком.
17
+ *
18
+ * Перестройка идёт с текущего состояния: журнал прорежен политикой хранения и обрезан purge. Строки
19
+ * источников читаются одним снимком S (курсор SQL держит снимок своего объявления), чекпоинт встаёт на
20
+ * (xmin(S), 0, S), а хвост журнала исключает транзакции, видимые в S. Перестройка пачками: у каждой
21
+ * пачки свой снимок Sᵢ, и событие хвоста пропускается, если его транзакция видна в снимке пачки, в
22
+ * которую попал id. Перестройка одного арендатора — свой снимок в meta.resync: хвост пропускает изменения
23
+ * этого арендатора, чья транзакция в нём видна.
24
+ */
25
+ import type postgres from 'postgres';
26
+ import { type Row } from './types.js';
27
+ import type { Signal } from './signals.js';
28
+ type Sql = postgres.Sql<Record<string, unknown>>;
29
+ /** Изменение для handle: версия журнала (как событие подписки) и, по withBefore, прошлая версия. */
30
+ export interface ProjectorChange {
31
+ /** Курсор события в журнале (при перестройке — пустая строка). */
32
+ cursor: string;
33
+ id: string;
34
+ class: string;
35
+ tenant: string;
36
+ /** create, update, delete, restore, reclass, rekey, …; служебные — purge, trim, reset (их rev — 1). */
37
+ op: string;
38
+ rev: number;
39
+ at: string;
40
+ author: string;
41
+ agent?: string;
42
+ reason?: string;
43
+ moved?: string;
44
+ /** Версия из журнала (надгробие — $deleted); при перестройке — текущая строка. */
45
+ row: Row;
46
+ /**
47
+ * withBefore: прошлая версия того же id; null — её нет. У первой версии нового id после reclass и rekey — null:
48
+ * прежние данные вычитает надгробие прежнего id (его before — последняя версия прежнего id). Строка вошла в семейство
49
+ * from сменой класса без смены id — тоже null: прошлая версия была вне семейства.
50
+ */
51
+ before?: Row | null;
52
+ /** Строка текущего состояния при перестройке (op — create). */
53
+ rebuild?: true;
54
+ }
55
+ /** Строка результата в формате загрузчика: поля и роли концов на верхнем уровне, служебные $tags, $owner, $id. */
56
+ export type ProjectorRow = Record<string, unknown>;
57
+ /** Запись результатов: handle получает изменения одного арендатора, out пишет только классы into этого арендатора. */
58
+ export interface ProjectorOut {
59
+ /** Создать или заменить целиком по ключу класса (у класса без ключа — по $id). */
60
+ upsert(cls: string, row: ProjectorRow, opts: {
61
+ tenant: string;
62
+ }): void;
63
+ /**
64
+ * Создать (поля без строки — от 0) или дополнить строку и прибавить числа: коммутативная свёртка без чтения.
65
+ * Приращения строки, заданной одними полями ключа (или одним $id), складываются до отправки в базу, пока между ними
66
+ * нет upsert, delete или get: одна правка строки на пачку.
67
+ */
68
+ inc(cls: string, row: ProjectorRow, deltas: Record<string, number>, opts: {
69
+ tenant: string;
70
+ }): void;
71
+ /** Удалить по ключу или id; строки нет — ничего. */
72
+ delete(cls: string, keyOrId: string | ProjectorRow, opts: {
73
+ tenant: string;
74
+ }): void;
75
+ /** Текущая строка внутри пачки (с записями пачки, сделанными до вызова); нет — null. */
76
+ get(cls: string, keyOrId: string | ProjectorRow, opts: {
77
+ tenant: string;
78
+ }): Promise<Row | null>;
79
+ }
80
+ export interface ProjectorOptions {
81
+ /** Классы-источники (с потомками); должны вести журнал. */
82
+ from: string[];
83
+ /** Производные классы (с потомками): описание каждого — derivedBy: имя проектора. */
84
+ into: string[];
85
+ /** Версия проектора: другая версия в чекпоинте — rebuild_required, нужна перестройка. */
86
+ version: number;
87
+ /** Изменения одного арендатора (пачка делится по арендаторам) и запись его результатов. */
88
+ handle(changes: ProjectorChange[], out: ProjectorOut): void | Promise<void>;
89
+ /** Подавать прошлую версию объекта (before). */
90
+ withBefore?: boolean;
91
+ /**
92
+ * Курсор старше яруса all политики хранения источников (cursor_expired): 'rebuild' — перестроить самому, пачками.
93
+ * Ярус — по арендатору: истёк у части участников — выбывают они (с 'rebuild' — перестраиваются сами, каждый своей
94
+ * транзакцией), у всех — вся проекция.
95
+ */
96
+ onExpired?: 'rebuild' | 'error';
97
+ /** Изменений в пачке (по умолчанию 500). */
98
+ batch?: number;
99
+ /** Запасной опрос журнала, мс (по умолчанию 1000; сигналы источников будят раньше). */
100
+ pollMs?: number;
101
+ /**
102
+ * Сколько транзакция проектора ждёт чужую блокировку строки, мс (по умолчанию 5000; 0 — без предела). Дольше —
103
+ * пачка откатывается, курсор остаётся, run() не отклоняется, а пропускает проход и повторяет его.
104
+ */
105
+ lockTimeout?: number;
106
+ /**
107
+ * Явный список арендаторов проектора: участники — только они (и только те, у кого каждый класс into помечен
108
+ * derivedBy). Без списка участник — любой арендатор с такими классами.
109
+ */
110
+ tenants?: string[];
111
+ }
112
+ export interface ProjectorReport {
113
+ /** Пачек журнала, записанных проектором. */
114
+ batches: number;
115
+ /** Изменений, переданных в handle (при перестройке — строк источников). */
116
+ changes: number;
117
+ created: number;
118
+ updated: number;
119
+ deleted: number;
120
+ unchanged: number;
121
+ /** Пачек, пропущенных из-за блокировки: проектор работает в другом процессе. */
122
+ skipped: number;
123
+ /** Перестроек. */
124
+ rebuilds: number;
125
+ }
126
+ /** Выбывший арендатор: база отклонила запись его результатов. */
127
+ export interface ProjectorFailure {
128
+ /** Курсор первого неприменённого изменения арендатора (ошибка при перестройке — курсор её начала). */
129
+ cursor: string;
130
+ /**
131
+ * Код ошибки letopis (invalid_data, target_not_found, …) или SQLSTATE; cursor_expired — истёк ярус all источников
132
+ * арендатора, invalid_class — его источники не ведут журнал (history: false).
133
+ */
134
+ code: string;
135
+ /** Текст ошибки: проектор, арендатор и id строки. */
136
+ message: string;
137
+ }
138
+ export interface ProjectorStatus {
139
+ name: string;
140
+ /** new — проекции ещё нет; rebuilding — идёт (или прервана) перестройка пачками. */
141
+ state: 'new' | 'running' | 'rebuilding';
142
+ version: number | null;
143
+ cursor: string | null;
144
+ /** Время последнего прохода (события или пустой пачки). */
145
+ at: string | null;
146
+ /** Отставание, мс: возраст самого старого необработанного изменения источников; 0 — догнал. */
147
+ lag: number;
148
+ /** Выбывшие арендаторы (id → ошибка): их изменения пропускаются до rebuild({ tenant }) или полной перестройки. */
149
+ failed: Record<string, ProjectorFailure>;
150
+ /** Арендаторы, которых перестраивают пачками по одному (rebuild({ tenant, chunked: true }), onExpired: 'rebuild'). */
151
+ rebuildingTenants: string[];
152
+ }
153
+ export interface ProjectorEnv {
154
+ sql: Sql;
155
+ schema: string;
156
+ /** Схема в кавычках. */
157
+ S: string;
158
+ ownerRole: string;
159
+ /** Подписка на сигналы базы (нет LISTEN — только опрос). */
160
+ onSignal?: (fn: (s: Signal) => void) => () => void;
161
+ }
162
+ export declare class Projector {
163
+ readonly env: ProjectorEnv;
164
+ private def;
165
+ readonly name: string;
166
+ /** Области арендаторов проектора: свои семейства from и into. */
167
+ private scopes;
168
+ private from;
169
+ /** Арендаторы проектора: каждый класс into у них помечен его derivedBy (и, с опцией tenants, они в списке). */
170
+ private tenants;
171
+ /** Самый короткий ярус all политик хранения источников арендатора, мс (нет в карте — предела нет). */
172
+ private lim;
173
+ /** Источники арендатора без журнала (history: false): их изменений проектор не увидит — арендатор выбывает. */
174
+ private blind;
175
+ private stale;
176
+ /** В чекпоинте есть начатая перестройка арендатора пачками (по последней пачке; до первой — проверить). */
177
+ private tenantsPending;
178
+ private running;
179
+ private stopped;
180
+ private wake;
181
+ private off;
182
+ private readonly batch;
183
+ private readonly pollMs;
184
+ private readonly lockMs;
185
+ private readonly only;
186
+ constructor(env: ProjectorEnv, name: string, def: ProjectorOptions);
187
+ /**
188
+ * Догнать журнал и следовать за ним до stop(); { once: true } — только догнать. Первый запуск (проекции
189
+ * ещё нет) начинается с перестройки пачками, прерванная перестройка пачками продолжается; так же, пачками,
190
+ * перестраивает onExpired: 'rebuild' (решение точки Б: горизонт подписок кластера стоит не дольше пачки).
191
+ */
192
+ run(opts?: {
193
+ once?: boolean;
194
+ }): Promise<ProjectorReport>;
195
+ /**
196
+ * Перестроить проекцию с текущего состояния: атомарно (одна транзакция: до фиксации читатели видят
197
+ * прежние строки, но горизонт подписок стоит) или { chunked: true } — пачками: производные строки
198
+ * удаляются сразу, до конца перестройки данные неполные (status() — rebuilding); прерванную перестройку
199
+ * продолжает следующий запуск. Полная перестройка начинает всех участников заново: отметки выбывших снимаются,
200
+ * а чьи данные снова не проходят — выбывают снова. { tenant: id } — только этот арендатор, одной транзакцией:
201
+ * его производные строки удаляются и строятся из его текущих строк источников, отметка выбывшего снимается.
202
+ * { tenant, chunked: true } — этот арендатор пачками, пока хвост журнала идёт для всех (доработки после точки Б):
203
+ * его производные строки удаляются сразу и до конца перестройки неполны; блокировку, занятую run() другого процесса,
204
+ * перестройка пережидает.
205
+ */
206
+ rebuild(opts?: {
207
+ chunked?: boolean;
208
+ tenant?: string;
209
+ }): Promise<ProjectorReport>;
210
+ status(): Promise<ProjectorStatus>;
211
+ /** Остановить run() после текущей пачки. */
212
+ stop(): void;
213
+ /** stop() и отписка от сигналов. */
214
+ close(): void;
215
+ /** Транзакция под ролью владельца на отдельном соединении пула. */
216
+ private tx;
217
+ private lock;
218
+ private state;
219
+ private advance;
220
+ private now;
221
+ /** Отметка выбывшего: источники арендатора не ведут журнал. */
222
+ private blindFailure;
223
+ private blindError;
224
+ private versionError;
225
+ /**
226
+ * Арендаторы и классы по строкам Class всех арендаторов (реестр администраторского подключения без сессии
227
+ * видит только System). Проектор работает с арендаторами, у которых каждый класс into помечен его
228
+ * derivedBy (с опцией tenants — только из списка); изменения остальных арендаторов пропускаются — иначе
229
+ * любой арендатор, заведя одноимённый класс без пометки, остановил бы проектор для всех. Источники — семейства
230
+ * from у этих арендаторов, кроме собственных результатов.
231
+ */
232
+ private resolve;
233
+ /** Одна пачка журнала. */
234
+ private step;
235
+ /** Прошлые версии одним запросом: последняя версия того же id с меньшим номером, кроме служебных записей. */
236
+ private previous;
237
+ /**
238
+ * Прошлые версии (id, rev − 1 и раньше) — всегда того же id. У первой версии нового id после reclass и rekey прошлой
239
+ * версии нет (null): прежние данные вычитает надгробие прежнего id. Прежде её before была последняя версия прежнего
240
+ * id, и свёртка вычитала её дважды — через неё и через надгробие. Прошлая версия вне семейства from арендатора —
241
+ * тоже null: строка вошла в семейство сменой класса без смены id, и в проекции её не было (ревью безопасности №3, №4).
242
+ */
243
+ private fillBefore;
244
+ /**
245
+ * Смена класса без смены id (класс без ключа: сырой update set class, reclass()), уводящая строку из семейства from:
246
+ * журнал пишет версию уже другого класса, а для проекции это удаление (ревью безопасности №3, №4). Изменение — op
247
+ * delete с $deleted, строка — последняя версия в семействе, номер, время и автор — смены класса. Прошлая версия вне
248
+ * семейства арендатора — изменения нет.
249
+ */
250
+ private leaving;
251
+ /**
252
+ * handle и запись результатов в транзакции пачки — группами по арендатору (ревью безопасности №2). failed —
253
+ * отметки выбывших этой пачки: каждая группа идёт под точкой сохранения, и ошибка данных арендатора (tenantFault:
254
+ * отказ базы при записи или чтении его результатов, отказ out по строке или приращениям) откатывает только группу,
255
+ * а арендатор попадает в failed (курсор — первого изменения группы, у перестройки — start). Исключение handle,
256
+ * нарушение границ out и сбой окружения пачку не спасают: откатывается всё. failed null (rebuild({ tenant })) — без
257
+ * точек сохранения: любая ошибка откатывает перестройку.
258
+ */
259
+ private apply;
260
+ /**
261
+ * Перестройка. 'auto' — путь run() (проекции ещё нет, onExpired: 'rebuild'): пачками, а прерванная перестройка
262
+ * пачками продолжается с места обрыва; блокировку держит другой процесс — 'busy' (run() подождёт), а не
263
+ * projector_busy, как у rebuild(). Иначе — как просили в rebuild().
264
+ */
265
+ private rebuildNow;
266
+ /** Строка текущего состояния как изменение перестройки; прошлой версии у неё нет. */
267
+ private rebuildChange;
268
+ /** Изменение — из семейства from своего арендатора (сброс арендатора — тоже); выбывший арендатор пропускается. */
269
+ private ours;
270
+ /** Смена класса без смены id, после которой строка вне семейства from арендатора, — возможно, уход из него (leaving). */
271
+ private leaves;
272
+ /** Класс результата в арендаторе — из его семейства into. */
273
+ intoOf(tenant: string, cls: string): boolean;
274
+ /**
275
+ * Ключ свёртки inc: класс и строка из одних полей ключа класса или одного $id; иначе null. Строку с другими полями
276
+ * (данные, $tags, $owner) inc не складывает: её поля правят строку, и порядок таких правок важен.
277
+ */
278
+ foldKey(tenant: string, cls: string, row: ProjectorRow): string | null;
279
+ private busy;
280
+ /** Производные строки арендаторов tenants — только семейства into каждого из них (derived_clear по парам). */
281
+ private clear;
282
+ /** Атомарная перестройка: одна транзакция, источники — курсором одним снимком. */
283
+ private atomic;
284
+ /**
285
+ * Перестройка пачками: своя транзакция и свой снимок у каждой пачки источников. auto — путь run(): занятую другим
286
+ * процессом блокировку он пережидает ('busy'), а новую перестройку не начинает, если её уже начал или закончил
287
+ * другой процесс (курсор чекпоинта — не тот, что видел run()): тогда только продолжает начатую.
288
+ */
289
+ private chunked;
290
+ /**
291
+ * Перестройка одного арендатора (rebuild({ tenant })): одной транзакцией его производные строки удаляются и
292
+ * строятся заново из его текущих строк источников, отметка выбывшего снимается. Снимок перестройки встаёт в
293
+ * чекпоинт (meta.resync): изменения арендатора, чья транзакция в нём видна, хвост журнала пропускает — каждое
294
+ * изменение применено ровно один раз. Ошибка записи откатывает перестройку: арендатор остаётся выбывшим.
295
+ */
296
+ private rebuildTenant;
297
+ /** Предусловия перестройки одного арендатора (как у rebuild({ tenant })). */
298
+ private tenantAdmit;
299
+ /**
300
+ * Начало перестройки арендатора пачками: его производные строки удаляются, отметка выбывшего снимается, в чекпоинт
301
+ * встают пустая карта пачек и время начала. auto — путь run(): занятая блокировка — 'busy', а не projector_busy.
302
+ */
303
+ private startTenant;
304
+ /**
305
+ * Пачка перестройки арендатора: своя транзакция и свой снимок. Ошибка данных арендатора — он выбывает с её кодом,
306
+ * его частично построенные строки удаляются, перестройка снимается; источников больше нет — перестройка закончена.
307
+ */
308
+ private tenantChunk;
309
+ /**
310
+ * Перестройка арендаторов пачками — проход run(): с start (onExpired: 'rebuild') начинает её у каждого выбывшего по
311
+ * cursor_expired, затем продвигает на пачку каждую начатую — автоматическую и прерванную rebuild({ tenant, chunked:
312
+ * true }). true — работа ещё осталась.
313
+ */
314
+ private tenantWork;
315
+ /** Ждать сигнал источников, остановку или запасной опрос. */
316
+ private sleep;
317
+ }
318
+ export {};