letopis 1.0.0 → 1.1.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/index.js CHANGED
@@ -11,6 +11,7 @@ import postgres from 'postgres';
11
11
  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
+ import { checkReason } from './write.js';
14
15
  import { makeAuth } from './auth.js';
15
16
  import { makeAcl } from './acl.js';
16
17
  import { idOf as idOfClass } from './uuid.js';
@@ -19,6 +20,7 @@ import { makeBatch, startEntity, startStep } from './chain.js';
19
20
  import { DDL_REVISION } from './types.js';
20
21
  import { ACL_CLASSES, ResultCache } from './cache.js';
21
22
  import { Subscription } from './watch.js';
23
+ import { SIGNAL_MODES, Signals, bucket, throttle } from './signals.js';
22
24
  import { describe as describeModel, maintain as runMaintain, rekeyClass as runRekeyClass, reset as runReset, scheduleMaintain, trimHistory as runTrim, verify as runVerify, } from './admin.js';
23
25
  export { DDL_REVISION } from './types.js';
24
26
  export { uuidv5, v5Name, v5Id, idOf } from './uuid.js';
@@ -64,6 +66,7 @@ function makeDb(sh, scope, exec, owner, tx) {
64
66
  const txc = scope.txc;
65
67
  const chainCtx = {
66
68
  reg: scope.registry(), schema: sh.schema, exec, tenant: scope.tenant, owner, onQuery: sh.onQuery, slowMs: sh.slowMs,
69
+ reason: txc.reason ?? '', txReason: txc.reason ?? '',
67
70
  ...(sh.cache ? { cache: { store: sh.cache, who: () => ({ token: txc.token, account: txc.account, tenant: scope.tenant() }) } } : {}),
68
71
  };
69
72
  // реестр корня меняется после auth.switch — цепочки берут действующий
@@ -116,7 +119,10 @@ function makeDb(sh, scope, exec, owner, tx) {
116
119
  const classes = o.classes ? [...new Set(o.classes.flatMap((c) => reg.family(c).map((x) => x.name)))] : null;
117
120
  return new Subscription({
118
121
  S: sh.S, exec: poolExecutor(txc), classes,
119
- ...(sh.listen ? { onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); } } : {}),
122
+ ...(sh.listen ? {
123
+ onSignal: (fn) => { sh.signals.add(fn); return () => void sh.signals.delete(fn); },
124
+ tenant: () => (sh.allTenants ? null : sh.sig.tenantToken(scope.tenant())),
125
+ } : {}),
120
126
  }, o);
121
127
  },
122
128
  cache: {
@@ -141,22 +147,24 @@ function makeDb(sh, scope, exec, owner, tx) {
141
147
  const registry = await sh.registryFor(tenant, scoped);
142
148
  return makeDb(sh, { txc: scoped, tenant: () => tenant, registry: () => registry, root: false }, poolExecutor(scoped), nextOwner);
143
149
  },
144
- begin: async () => {
150
+ begin: async (o) => {
145
151
  if (tx)
146
152
  throw new LetopisError('invalid_query', 'вложенных транзакций нет: db.begin() внутри db.begin()');
147
- const t = await ExplicitTx.open(txc);
148
- return makeDb(sh, scope, t, owner, t);
153
+ // причина блока ставится в начале транзакции (setupSql) и действует на все её операции
154
+ const btx = o?.reason !== undefined ? { ...txc, reason: checkReason(o.reason, 'db.begin()') } : txc;
155
+ const t = await ExplicitTx.open(btx);
156
+ return makeDb(sh, btx === txc ? scope : { ...scope, txc: btx }, t, owner, t);
149
157
  },
150
158
  load: async (input, o) => {
151
159
  if (tx)
152
160
  throw new LetopisError('invalid_query', 'load() — своей транзакцией, не внутри db.begin()');
153
161
  return load({ sql: sh.sql, schema: sh.schema, tenant: scope.tenant(), admin: sh.admin, ownerRole: sh.ownerRole }, input, o);
154
162
  },
155
- batch: (name) => {
163
+ batch: (name, o) => {
156
164
  let q = batches.get(name);
157
165
  if (!q)
158
166
  batches.set(name, (q = []));
159
- return makeBatch(chainCtx, q);
167
+ return makeBatch(o?.reason !== undefined ? { ...chainCtx, reason: checkReason(o.reason, 'db.batch()') } : chainCtx, q);
160
168
  },
161
169
  lock: async (...keys) => (tx ? tx.lock(...keys) : noTx('lock()')),
162
170
  commit: async (tr) => (tr ? tr.commit() : tx ? tx.commit() : noTx('commit()')),
@@ -227,10 +235,20 @@ export async function connect(opts) {
227
235
  throw new LetopisError('invalid_data', `connect: схемы ${opts.schema} нет — установите её: up({ schema })`);
228
236
  if (rev < DDL_REVISION)
229
237
  console.warn(`letopis: движок схемы ${opts.schema} ревизии ${rev}, библиотека ждёт ${DDL_REVISION} — обновите: up({ schema: '${opts.schema}', upgrade: true })`);
238
+ // режим сигналов установки (план 1.1, §2.2); схема, накатанная до него (нет signal_mode()), — class
239
+ const [sm] = await sql.unsafe(`select ${S}.signal_mode() as m`).catch((e) => {
240
+ if (e.code === '42883')
241
+ return [{ m: 'class' }];
242
+ throw fromDbError(e);
243
+ });
244
+ const mode = SIGNAL_MODES.includes(sm?.m) ? sm.m : 'class';
245
+ const sig = new Signals(mode);
230
246
  const notifier = new Notifier(sql, opts.schema, opts.notifyIntervalMs ?? 100);
231
247
  const passes = new AclPasses(S);
232
- const listen = opts.listen !== false;
233
- const cache = opts.cache === false ? null : new ResultCache(opts.cache ?? {});
248
+ // режим none: сигналов нет — без LISTEN, кэш только по сроку (ttlOnly), подписка опрашивает журнал
249
+ const listen = opts.listen !== false && mode !== 'none';
250
+ // подключение в обход RLS видит строки всех арендаторов: его ответы сбрасывает сигнал из любого
251
+ const cache = opts.cache === false ? null : new ResultCache(opts.cache ?? {}, sig, Boolean(bypass));
234
252
  const signals = new Set();
235
253
  // продление сессии (§2.9): отдельным вызовом не чаще раза в минуту; ошибка (реплика,
236
254
  // транзакции только для чтения) не мешает работе
@@ -241,10 +259,14 @@ export async function connect(opts) {
241
259
  refreshedAt = Date.now();
242
260
  void runTx({ sql, token, notifier: null }, (q) => q.unsafe(`select ${S}.auth_refresh()`), { retries: 0 }).catch(() => { });
243
261
  };
244
- const ctx = { sql, token, notifier, passes, after: touch, ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
262
+ // в режиме none нотификатор не отправляет: letopis.changed своей транзакции сбрасывает только свой кэш
263
+ const ctx = { sql, token, notifier: mode === 'none' ? null : notifier, passes, after: touch,
264
+ ...(cache ? { onChanged: (names, raw) => cache.changed(names, raw) } : {}) };
245
265
  const root = { tenant: null, registry: new Registry() };
246
266
  const loadClasses = (txc) => runTx(txc, (q) => q.unsafe(`select tenant, rev, data from ${S}.entity where class = 'Class' and tenant in (${S}.ctx_tenant(), '${SYSTEM_ID}')`), { read: true });
247
267
  const others = new Map();
268
+ // токены классов (режим opaque) — по именам всех реестров подключения
269
+ const tokens = () => sig.setClasses([root.registry, ...[...others.values()].map((o) => o.registry)].flatMap((r) => r.all().map((c) => c.name)));
248
270
  const refresh = async () => {
249
271
  const rows = await runTx(ctx, async (q) => {
250
272
  const [t] = await q.unsafe(`select ${S}.ctx_tenant() as t`);
@@ -254,6 +276,7 @@ export async function connect(opts) {
254
276
  root.registry.load(rows);
255
277
  for (const o of others.values())
256
278
  o.registry.load(await loadClasses(o.txc).catch(() => []));
279
+ tokens();
257
280
  };
258
281
  const registryFor = async (tenant, txc) => {
259
282
  if (tenant === root.tenant)
@@ -264,37 +287,118 @@ export async function connect(opts) {
264
287
  const registry = new Registry();
265
288
  registry.load(await loadClasses(txc));
266
289
  others.set(tenant, { registry, txc });
290
+ tokens();
267
291
  return registry;
268
292
  };
293
+ // ключ сигналов (режимы tenant и opaque) — сессии с правом letopis.signals; без права процесс
294
+ // работает без ключа: арендатора (а в opaque — и класса) в сигнале не различает
295
+ const fetchKey = async () => {
296
+ if (mode !== 'tenant' && mode !== 'opaque')
297
+ return;
298
+ const key = await runTx(ctx, (q) => q.unsafe(`select ${S}.signal_key() as k`), { read: true }).then((r) => r[0]?.k ?? null, (e) => {
299
+ if (e instanceof LetopisError && (e.code === 'acl_denied' || e.code === 'no_session'))
300
+ return null;
301
+ throw e;
302
+ });
303
+ // ключ сменился (reset({ all })): ответы с токенами старого ключа сигналы больше не узнают
304
+ if (sig.setKey(key))
305
+ cache?.clear();
306
+ };
307
+ await fetchKey();
269
308
  await refresh();
270
- // описания классов меняются — реестр перечитывается по сигналу; строки прав — пропуска заново;
271
- // кэш результатов и подписки — по именам классов; (пере)подключение слушателя — кэш целиком
272
- const onSignal = (cls) => {
273
- if (ACL_CLASSES.has(cls) || cls === '*')
309
+ // ключ, реестр и пропуска прав — заново по *, незнакомому токену, нагрузке не по режиму, а у процесса
310
+ // без ключа в режиме opaque — по любому сигналу; не чаще раза в 5 с: заливка канала (в том числе
311
+ // поддельными сигналами) не становится потоком перечитываний
312
+ const resync = throttle(() => {
313
+ passes.clear();
314
+ void fetchKey().then(refresh).catch(() => { });
315
+ }, 5000);
316
+ // сигнал Class — реестр сразу, как в 1.0: правка описания доходит без задержки; пока идёт перечитывание, новые
317
+ // сигналы сливаются в одно следующее. Три перечитывания подряд — сразу, дальше не чаще раза в 5 с: заливка
318
+ // канала, в том числе поддельными Class, не становится потоком перечитываний (план 1.1, §7.7 №32; ревью №1
319
+ // точки А)
320
+ let reloading = false;
321
+ let again = false;
322
+ const startReload = bucket(() => {
323
+ reloading = true;
324
+ void refresh().catch(() => { }).finally(() => {
325
+ reloading = false;
326
+ if (again) {
327
+ again = false;
328
+ reload();
329
+ }
330
+ });
331
+ }, 3, 5000);
332
+ const reload = () => {
333
+ if (reloading) {
334
+ again = true;
335
+ return;
336
+ }
337
+ startReload();
338
+ };
339
+ // строки прав — пропуска заново; кэш результатов и подписки — по классу и арендатору сигнала;
340
+ // (пере)подключение слушателя — кэш целиком, после обрыва — и ключ с реестром (сигналы потеряны)
341
+ const onSignal = (payload) => {
342
+ const s = sig.parse(payload);
343
+ if (s.kind === 'class' && ACL_CLASSES.has(s.cls))
274
344
  passes.clear();
275
- if (cls === 'Class' || cls === '*')
276
- void refresh().catch(() => { });
277
- cache?.signal(cls);
345
+ if (s.kind === 'class' && s.cls === 'Class')
346
+ reload();
347
+ else if (s.kind === 'all' || s.kind === 'opaque' || s.kind === 'unknown' || s.kind === 'foreign')
348
+ resync();
349
+ cache?.signal(s);
278
350
  for (const fn of signals)
279
- fn(cls);
351
+ fn(s);
280
352
  };
353
+ let listened = false;
281
354
  const listener = listen
282
355
  ? await sql.listen(opts.schema, onSignal, () => {
356
+ if (listened)
357
+ resync();
358
+ listened = true;
283
359
  cache?.listen();
284
360
  for (const fn of signals)
285
- fn('*');
361
+ fn({ kind: 'all' });
286
362
  })
287
363
  : null;
364
+ // режим none: о новых и изменённых классах сигнала нет — отпечаток классов раз в 5 с
365
+ let stamper = null;
366
+ if (mode === 'none') {
367
+ const stampOf = (txc) => runTx(txc, (q) => q.unsafe(`select ${S}.class_stamp() as s`), { read: true }).then((r) => String(r[0]?.s ?? ''));
368
+ const stamp = async () => [await stampOf(ctx), ...(await Promise.all([...others.values()].map((o) => stampOf(o.txc).catch(() => ''))))].join('|');
369
+ let last = await stamp();
370
+ let busy = false;
371
+ stamper = setInterval(() => {
372
+ if (busy)
373
+ return;
374
+ busy = true;
375
+ void stamp().then(async (cur) => {
376
+ if (cur === last)
377
+ return;
378
+ last = cur;
379
+ await refresh();
380
+ }).catch(() => { }).finally(() => {
381
+ busy = false;
382
+ });
383
+ }, 5000);
384
+ stamper.unref?.();
385
+ }
288
386
  let stopMaintain = null;
289
387
  const shared = {
290
388
  sql, schema: opts.schema, S, notifier, passes, root, registryFor, refresh, onQuery: opts.onQuery, slowMs: opts.slowMs,
291
389
  admin: Boolean(bypass && (role.rolsuper || role.can_set)),
292
390
  ownerRole: owner,
293
391
  cache,
392
+ sig,
393
+ allTenants: Boolean(bypass),
294
394
  signals,
295
395
  listen,
296
396
  async close() {
297
397
  stopMaintain?.();
398
+ resync.stop();
399
+ startReload.stop();
400
+ if (stamper)
401
+ clearInterval(stamper);
298
402
  await notifier.close();
299
403
  await listener?.unlisten();
300
404
  if (own)
package/dist/load.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type postgres from 'postgres';
2
2
  import { type Issue } from './errors.js';
3
+ import { Registry } from './registry.js';
3
4
  type Sql = postgres.Sql<Record<string, unknown>>;
4
5
  type Raw = Record<string, unknown>;
5
6
  export interface LoadOptions {
@@ -27,6 +28,12 @@ export interface LoadOptions {
27
28
  * existing: 'update' — прежние) значения строк; цели ссылок ему видны; владелец — по ownerDefault.
28
29
  */
29
30
  as?: string;
31
+ /**
32
+ * Первая заливка (план 1.1, §2.3): только в пустую схему (кроме строк системных классов — иначе
33
+ * not_empty), одной транзакцией; на время загрузки схема закрыта, индексы и триггеры сняты и
34
+ * перестраиваются в конце. Без commitEvery, existing (кроме 'error') и { as }.
35
+ */
36
+ initial?: boolean;
30
37
  }
31
38
  export interface LoadReport {
32
39
  /** Строк во входе. */
@@ -65,6 +72,50 @@ export interface LoadEnv {
65
72
  /** Подключение может стать владельцем (set role) — иначе admin_required. */
66
73
  admin: boolean;
67
74
  ownerRole: string;
75
+ /** Сессия первой заливки (initialSession): загрузка идёт на её соединении и в её транзакции. */
76
+ session?: InitialState;
77
+ }
78
+ /** Внутренние настройки первой заливки — для разбивки замера (bench/initial-load.mjs); в публичные типы не входят. */
79
+ export interface InitialTuning {
80
+ /** COPY … FREEZE (по умолчанию да). */
81
+ freeze?: boolean;
82
+ /** Оставить триггеры (BEFORE ROW делает COPY построчным). */
83
+ keepTriggers?: boolean;
84
+ /** Оставить первичный ключ и уникальные ограничения. */
85
+ keepKeys?: boolean;
86
+ /** Оставить индексы GIN. */
87
+ keepGin?: boolean;
88
+ }
89
+ /** Проверки загрузки, отложенные до конца первой заливки: по перестроенным индексам. */
90
+ interface Pending {
91
+ reg: Registry;
92
+ tenant: string;
93
+ external: Map<string, {
94
+ line: number;
95
+ role: string;
96
+ allowed: string[];
97
+ }>;
98
+ owners: Map<string, number>;
99
+ /** verify: true — id записанных строк. */
100
+ verify: string[] | null;
101
+ }
102
+ /** Состояние сессии первой заливки. */
103
+ export interface InitialState {
104
+ conn: Sql;
105
+ /** id строк системных классов и объектов, уже записанных сессией: первичного ключа на время загрузки нет. */
106
+ ids: Set<string>;
107
+ pending: Pending[];
108
+ /** Арендатор → классы записанных строк: сигнал в конце сессии. */
109
+ touched: Map<string, Set<string>>;
110
+ freeze: boolean;
111
+ }
112
+ /** Первая заливка для нескольких загрузок (импорт): соединение и транзакция сессии. */
113
+ export interface InitialSession {
114
+ /** Соединение сессии (роль владельца): свои операторы импорта — в той же транзакции. */
115
+ readonly sql: Sql;
116
+ load(input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
117
+ /** id уже есть в схеме: строка системного класса или объект, записанный этой сессией. */
118
+ taken(id: string): boolean;
68
119
  }
69
120
  /**
70
121
  * Загрузить строки (§2.6). Одна транзакция (или порции commitEvery): описания классов под
@@ -73,4 +124,16 @@ export interface LoadEnv {
73
124
  * называет строки и нарушения.
74
125
  */
75
126
  export declare function load(env: LoadEnv, input: LoadInput, opts?: LoadOptions): Promise<LoadReport>;
127
+ /**
128
+ * Первая заливка (план 1.1, §2.3): загрузки fn — одной транзакцией в пустую схему. Блокировка таблиц до
129
+ * проверки; в схеме только строки системных классов и записи reset (иначе not_empty); они (с seq и tx
130
+ * журнала) и определения ограничений, индексов и триггеров из каталога (с режимом включения tgenabled:
131
+ * pg_get_triggerdef его не передаёт) сохраняются в памяти; TRUNCATE; триггеры, ограничения и индексы
132
+ * снимаются — BEFORE ROW делает COPY построчным одним своим наличием, триггеры после оператора копят
133
+ * переходные таблицы; системные строки возвращаются (seq журнала — generated always: overriding system
134
+ * value); загрузки пишут COPY FREEZE. В конце — ограничения (дубликат найдёт сортировка), индексы,
135
+ * триггеры с прежним режимом, отложенные проверки загрузок, сигнал, счётчик прав и ANALYZE. Любая ошибка
136
+ * откатывает всё: DDL в PostgreSQL транзакционен.
137
+ */
138
+ export declare function initialSession<T>(env: LoadEnv, fn: (s: InitialSession) => Promise<T>, tune?: InitialTuning): Promise<T>;
76
139
  export {};