letopis 1.0.1 → 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/cache.js CHANGED
@@ -6,23 +6,34 @@
6
6
  * запрос разным пользователям отдаёт разные строки. Сброс:
7
7
  * - свои записи — сразу после фиксации, по классам из letopis.changed (туда попадают и классы
8
8
  * каскада, unset, смены класса и ключа); запись через db.sql — весь кэш;
9
- * - чужие записи — по сигналу базы с именем класса; классы прав и схемы (rule, Resource, member,
10
- * Account, Class) и «*» — весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
11
- * - гонка запроса и сигнала: счётчики сигналов по классам и сессиям снимаются до запроса, ответ
12
- * кладётся, только если они не изменились;
9
+ * - чужие записи — по сигналу базы (нагрузка по режиму установки, signals.ts): класс в арендаторе
10
+ * сигнала (ответ помнит токен арендатора запроса; сигнал System и процесс без ключа — во всех), в режиме
11
+ * opaque без ключа — весь кэш; классы прав и схемы (rule, Resource, member, Account, Class) и «*» —
12
+ * весь кэш; `session:<16 hex>` — ответы погашенной сессии, `session:*` — все;
13
+ * - гонка запроса и сигнала: счётчики сигналов по классам, парам «класс, арендатор» и сессиям
14
+ * снимаются до запроса, ответ кладётся, только если они не изменились;
13
15
  * - (пере)подключение слушателя сигналов — весь кэш: сигналы за время обрыва потеряны; без слушателя
14
16
  * кэш выключен (с ttlOnly — работает только по сроку);
15
17
  * - срок (по умолчанию 60 с) — предохранитель; ответы истёкшей сессии не используются.
16
18
  * Внутри db.begin() и в db.sql кэш не работает.
17
19
  */
18
20
  import { createHash } from 'node:crypto';
21
+ import { ACL_CLASS_NAMES } from './types.js';
22
+ import { Signals } from './signals.js';
19
23
  /** Классы, строки которых меняют права и схему: их сигнал сбрасывает весь кэш. */
20
- export const ACL_CLASSES = new Set(['rule', 'Resource', 'member', 'Account', 'Class']);
24
+ export const ACL_CLASSES = new Set(ACL_CLASS_NAMES);
25
+ /** Счётчиков сигналов в каждой карте не больше: имена в нагрузке присылает любая роль базы. */
26
+ const EPOCH_MAX = 100_000;
21
27
  /** Начало sha256 токена — так база называет сессию в сигнале session:<16 hex>. */
22
28
  export const sessionTag = (token) => token ? createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16) : '';
23
29
  export class ResultCache {
30
+ sig;
31
+ allTenants;
24
32
  map = new Map();
25
33
  classEpoch = new Map();
34
+ /** Сигналы класса в арендаторе: по паре и по классу в любом арендаторе (для ответов без арендатора). */
35
+ pairEpoch = new Map();
36
+ anyEpoch = new Map();
26
37
  sessionEpoch = new Map();
27
38
  epoch = 0;
28
39
  listening = false;
@@ -30,7 +41,13 @@ export class ResultCache {
30
41
  ttlMs;
31
42
  ttlOnly;
32
43
  max;
33
- constructor(opts = {}) {
44
+ /**
45
+ * sig — режим сигналов и ключ подключения (разбор нагрузки, токены арендаторов); allTenants —
46
+ * подключение видит строки всех арендаторов (обходит RLS): ответ сбрасывается сигналом из любого.
47
+ */
48
+ constructor(opts = {}, sig = new Signals(), allTenants = false) {
49
+ this.sig = sig;
50
+ this.allTenants = allTenants;
34
51
  this.ttlMs = (opts.ttl ?? 60) * 1000;
35
52
  this.ttlOnly = opts.ttlOnly === true;
36
53
  this.max = opts.max ?? 10_000;
@@ -43,14 +60,19 @@ export class ResultCache {
43
60
  key(text, params, who) {
44
61
  return JSON.stringify([text, params, who.token ?? '', who.account ?? '', who.tenant ?? '']);
45
62
  }
46
- /** Снимок счётчиков классов и сессии — до запроса. */
47
- token(classes, session) {
63
+ /** Снимок счётчиков классов, пар с арендатором запроса (id) и сессии — до запроса. */
64
+ token(classes, session, tenantId) {
65
+ const tenant = this.allTenants ? null : this.sig.tenantToken(tenantId);
48
66
  return {
49
67
  epoch: this.epoch,
50
- classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0]),
68
+ classes: classes.map((c) => [c, this.classEpoch.get(c) ?? 0, this.pairCount(c, tenant)]),
51
69
  session: this.sessionEpoch.get(session) ?? 0,
70
+ tenant,
52
71
  };
53
72
  }
73
+ pairCount(cls, tenant) {
74
+ return (tenant === null ? this.anyEpoch.get(cls) : this.pairEpoch.get(`${cls}@${tenant}`)) ?? 0;
75
+ }
54
76
  get(key) {
55
77
  if (!this.enabled)
56
78
  return undefined;
@@ -71,70 +93,97 @@ export class ResultCache {
71
93
  return false;
72
94
  const stale = tok.epoch !== this.epoch
73
95
  || tok.session !== (this.sessionEpoch.get(session) ?? 0)
74
- || tok.classes.some(([c, n]) => (this.classEpoch.get(c) ?? 0) !== n);
96
+ || tok.classes.some(([c, n, m]) => (this.classEpoch.get(c) ?? 0) !== n || this.pairCount(c, tok.tenant) !== m);
75
97
  if (stale) {
76
98
  this.st.raced++;
77
99
  return false;
78
100
  }
79
101
  if (this.map.size >= this.max)
80
102
  this.map.delete(this.map.keys().next().value);
81
- this.map.set(key, { value, classes: [...classes], session, expires: Date.now() + ttlMs, sessionExpires });
103
+ this.map.set(key, { value, classes: [...classes], session, tenant: tok.tenant, expires: Date.now() + ttlMs, sessionExpires });
82
104
  this.st.stores++;
83
105
  return true;
84
106
  }
85
- /** Сбросить ответы, читавшие эти классы. */
107
+ /** Сбросить ответы, читавшие эти классы (во всех арендаторах). */
86
108
  invalidate(classes) {
87
109
  const set = new Set(classes);
88
110
  if (!set.size)
89
111
  return;
90
112
  for (const c of set)
91
- this.classEpoch.set(c, (this.classEpoch.get(c) ?? 0) + 1);
113
+ this.bump(this.classEpoch, c);
92
114
  for (const [k, e] of this.map)
93
115
  if (e.classes.some((c) => set.has(c)))
94
116
  this.map.delete(k);
95
117
  }
118
+ /** Сбросить ответы класса в арендаторе (токен) и ответы без арендатора; null — во всех. */
119
+ invalidateIn(cls, tenant) {
120
+ if (tenant === null)
121
+ return this.invalidate([cls]);
122
+ this.bump(this.pairEpoch, `${cls}@${tenant}`);
123
+ this.bump(this.anyEpoch, cls);
124
+ for (const [key, e] of this.map)
125
+ if ((e.tenant === null || e.tenant === tenant) && e.classes.includes(cls))
126
+ this.map.delete(key);
127
+ }
96
128
  /** Сбросить ответы сессии (тег — начало sha256 токена). */
97
129
  invalidateSession(tag) {
98
- this.sessionEpoch.set(tag, (this.sessionEpoch.get(tag) ?? 0) + 1);
130
+ this.bump(this.sessionEpoch, tag);
99
131
  for (const [k, e] of this.map)
100
132
  if (e.session === tag)
101
133
  this.map.delete(k);
102
134
  }
135
+ /**
136
+ * Счётчик сигналов по ключу (класс, пара «класс, арендатор», сессия). Ключи приходят в нагрузке сигнала, а её
137
+ * может прислать любая роль базы: при переполнении карта очищается, и снимки в полёте устаревают целиком
138
+ * (epoch), — иначе заливка разными именами копила бы счётчики без предела (ревью №1 точки А плана 1.1).
139
+ */
140
+ bump(m, k) {
141
+ if (m.size >= EPOCH_MAX && !m.has(k)) {
142
+ m.clear();
143
+ this.epoch++;
144
+ }
145
+ m.set(k, (m.get(k) ?? 0) + 1);
146
+ }
103
147
  /** Весь кэш. */
104
148
  clear() {
105
149
  this.epoch++;
106
150
  this.map.clear();
107
151
  }
152
+ /** Сброс по разобранному сигналу. */
153
+ apply(s) {
154
+ switch (s.kind) {
155
+ case 'session':
156
+ return this.invalidateSession(s.tag);
157
+ case 'class':
158
+ return ACL_CLASSES.has(s.cls) ? this.clear() : this.invalidateIn(s.cls, s.tenant);
159
+ case 'unknown':
160
+ // класса нет в реестрах процесса — и ответов с ним нет
161
+ return;
162
+ default:
163
+ // *, session:*, opaque без ключа, нагрузка не по режиму
164
+ return this.clear();
165
+ }
166
+ }
108
167
  /**
109
- * Имена из сигнала или из letopis.changed своей транзакции: классы, `*`, `session:<тег>`,
110
- * `session:*`. raw — запись через db.sql: весь кэш.
168
+ * Нагрузки из letopis.changed своей транзакции (те же, что уйдут сигналом). raw — запись через db.sql:
169
+ * весь кэш.
111
170
  */
112
171
  changed(names, raw = false) {
113
- const classes = [];
114
172
  let any = false;
115
173
  for (const n of names) {
116
174
  if (!n)
117
175
  continue;
118
176
  any = true;
119
- if (n === '*' || n === 'session:*' || ACL_CLASSES.has(n)) {
120
- this.clear();
121
- return;
122
- }
123
- if (n.startsWith('session:'))
124
- this.invalidateSession(n.slice(8));
125
- else
126
- classes.push(n);
177
+ if (!raw)
178
+ this.apply(this.sig.parse(n));
127
179
  }
128
- if (raw && any) {
180
+ if (raw && any)
129
181
  this.clear();
130
- return;
131
- }
132
- this.invalidate(classes);
133
182
  }
134
- /** Сигнал от базы. */
135
- signal(name) {
183
+ /** Сигнал от базы: нагрузка или уже разобранный. */
184
+ signal(s) {
136
185
  this.st.signals++;
137
- this.changed([name]);
186
+ this.apply(typeof s === 'string' ? this.sig.parse(s) : s);
138
187
  }
139
188
  /** Слушатель (пере)подключился: сигналы за время обрыва потеряны — весь кэш. */
140
189
  listen() {
package/dist/chain.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type ResultCache } from './cache.js';
2
2
  import type { ClassInfo, Registry } from './registry.js';
3
3
  import { type Step } from './sql.js';
4
4
  import type { Executor } from './tx.js';
5
- import type { ChainMods, Cursor, Filter, Path, QueryEvent, Row } from './types.js';
5
+ import type { BlameOptions, BlameResult, ChainMods, Cursor, DiffOptions, DiffResult, Filter, Path, QueryEvent, Row } from './types.js';
6
6
  export interface ChainCtx {
7
7
  readonly reg: Registry;
8
8
  readonly schema: string;
@@ -16,6 +16,9 @@ export interface ChainCtx {
16
16
  readonly slowMs?: number;
17
17
  /** Цепочка из батча: план копится в очереди до batch.run(). */
18
18
  readonly batch?: BatchRef;
19
+ /** Причина изменения по умолчанию для глаголов (db.batch(имя, { reason })) и та, что стоит в транзакции (db.begin({ reason })). */
20
+ readonly reason?: string;
21
+ readonly txReason?: string;
19
22
  /** Кэш результатов подключения и тот, кто спрашивает (ключ ответа, §2.12). */
20
23
  readonly cache?: {
21
24
  store: ResultCache;
@@ -61,6 +64,10 @@ export interface ChainCore {
61
64
  versions(opts?: {
62
65
  follow?: boolean;
63
66
  }): Promise<Row[]>;
67
+ /** Что изменилось между двумя версиями или моментами (по умолчанию — предыдущая версия → последняя), план 1.1. */
68
+ diff(opts?: DiffOptions): Promise<DiffResult[]>;
69
+ /** Кто, когда и почему последним изменил каждое поле версии (по умолчанию текущей), план 1.1. */
70
+ blame(opts?: BlameOptions): Promise<BlameResult[]>;
64
71
  /** Агрегаты по полю последнего шага ('data.<путь>') — по сущностям, считает база. */
65
72
  sum(field: string): Promise<number | null>;
66
73
  avg(field: string): Promise<number | null>;
@@ -92,31 +99,46 @@ export interface ChainCore {
92
99
  /** Вставить узел: ленивая цепочка-паттерн или строка ответа; та же переменная повторно — возврат к её узлу. */
93
100
  entity(x: Chain | Row): Chain;
94
101
  /** Создать объект: концы — из пути и слотов; занятый ключ — ошибка exists с id. */
95
- create(data?: Record<string, unknown>): Chain;
102
+ create(data?: Record<string, unknown>, opts?: {
103
+ reason?: string;
104
+ }): Chain;
96
105
  /** Новая версия каждой цели шага: слияние патча в базе; inc(n) — приращение; { rev } — строгий режим. */
97
106
  update(patch?: Record<string, unknown>, opts?: {
98
107
  rev?: number;
108
+ reason?: string;
99
109
  }): Chain;
100
110
  /** Создать или заменить целиком (класс с ключом); строка ответа несёт $upsert. */
101
- upsert(data: Record<string, unknown>): Chain;
111
+ upsert(data: Record<string, unknown>, opts?: {
112
+ reason?: string;
113
+ }): Chain;
102
114
  /** Удалить цели шага с каскадом; без { confirm: true } — превью замыкания ($action). */
103
115
  delete(opts?: {
104
116
  confirm?: boolean;
117
+ reason?: string;
105
118
  }): Chain;
106
119
  /** Затереть строковые поля ('[erased]') и пометить тегом anonymized. */
107
- anonymize(fields: string[]): Chain;
120
+ anonymize(fields: string[], opts?: {
121
+ reason?: string;
122
+ }): Chain;
108
123
  /** Сменить класс: у класса с ключом — новый id и перевод ссылок. */
109
- reclass(cls: string, data?: Record<string, unknown>): Chain;
124
+ reclass(cls: string, data?: Record<string, unknown>, opts?: {
125
+ reason?: string;
126
+ }): Chain;
110
127
  /** Чтение с блокировкой строк последнего шага до конца db.begin() (rows, first, ids). */
111
128
  forUpdate(): Chain;
112
129
  /** Восстановить удалённые объекты шага: последний снимок новой версией (id прежний). */
113
- restore(): Chain;
130
+ restore(opts?: {
131
+ reason?: string;
132
+ }): Chain;
114
133
  /** Стереть историю удалённых объектов шага; без { confirm: true } — превью ($versions). */
115
134
  purge(opts?: {
116
135
  confirm?: boolean;
136
+ reason?: string;
117
137
  }): Chain;
118
138
  /** Новое значение ключа: строка с новым id, ссылки переведены (поля — данные, роли — концы). */
119
- rekey(patch: Record<string, unknown>): Chain;
139
+ rekey(patch: Record<string, unknown>, opts?: {
140
+ reason?: string;
141
+ }): Chain;
120
142
  /** Кэш результатов (§2.12): ответ хранится в памяти процесса ttl секунд (по умолчанию 60). */
121
143
  cache(opts?: {
122
144
  ttl?: number;
package/dist/chain.js CHANGED
@@ -102,7 +102,7 @@ async function exec(ctx, q, mode, steps, mods = {}) {
102
102
  if (hit)
103
103
  return hit;
104
104
  const tag = sessionTag(who.token);
105
- const tok = store.token(classes, tag);
105
+ const tok = store.token(classes, tag, who.tenant);
106
106
  const t0 = performance.now();
107
107
  const S = `"${ctx.schema.replace(/"/g, '""')}"`;
108
108
  const [res, exp] = await ctx.exec.run((tx) => Promise.all([
@@ -139,7 +139,7 @@ async function read(ctx, steps, mods, mode, agg) {
139
139
  async function plan(ctx, steps, mods, mode, agg) {
140
140
  precheckPlan(ctx.reg, steps);
141
141
  const t0 = performance.now();
142
- const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, steps, mods, mode, agg));
142
+ const r = await ctx.exec.run((q) => execPlan({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, steps, mods, mode, agg));
143
143
  if (r.kind === 'read') {
144
144
  report(ctx, mode === 'countPaths' ? 'count' : mode, steps, t0, r.res.length);
145
145
  if (r.q === null) {
@@ -196,6 +196,28 @@ async function lockedRead(ctx, steps, mods, mode) {
196
196
  report(ctx, mode, steps, t0, res.length);
197
197
  return { q: rowsQ, res: mode === 'ids' ? res.map((r) => ({ id: r.row.id })) : res };
198
198
  }
199
+ /** Причины для плана: по умолчанию у глаголов (у батча — своя) и та, что стоит в транзакции. */
200
+ const reasons = (ctx) => ({ reason: ctx.reason ?? ctx.txReason ?? '', txReason: ctx.txReason ?? '' });
201
+ /** Опция reason глагола (план 1.1, §2.1): проверяет precheckPlan. */
202
+ const reasonOf = (o) => (o?.reason !== undefined ? { reason: o.reason } : {});
203
+ /** diff({ from, to }): номер версии (целое ≥ 1) или момент (ISO-строка, Date). */
204
+ function versionRef(v, what, moments = true) {
205
+ if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 2147483647)
206
+ return v;
207
+ if (moments && v instanceof Date && !Number.isNaN(+v))
208
+ return v.toISOString();
209
+ if (moments && typeof v === 'string' && !Number.isNaN(Date.parse(v)))
210
+ return v;
211
+ throw new LetopisError('invalid_query', `${what}: номер версии (целое ≥ 1)${moments ? ' или момент (ISO-строка, Date)' : ''}`);
212
+ }
213
+ /** blame({ at }): момент (ISO-строка, Date). */
214
+ function momentOf(v, what) {
215
+ if (v instanceof Date && !Number.isNaN(+v))
216
+ return v.toISOString();
217
+ if (typeof v === 'string' && !Number.isNaN(Date.parse(v)))
218
+ return v;
219
+ throw new LetopisError('invalid_query', `${what}: момент — ISO-строка или Date`);
220
+ }
199
221
  /** Строка ответа: из чтения — to_jsonb узла, из плана — готовая строка записи. */
200
222
  const rowOf = (r) => (r.done ? r.row : toRow(r.row));
201
223
  /** Значение слота: id, список id, строка ответа или ленивая цепочка. */
@@ -283,6 +305,10 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
283
305
  const last = steps[steps.length - 1];
284
306
  if (!last)
285
307
  throw new LetopisError('invalid_query', `${op.kind}() — нужен шаг класса: db.Класс(…).${op.kind}(…)`);
308
+ // батч: причина db.batch(имя, { reason }) — при постановке в очередь, а не при run(): очередь общая по имени, и
309
+ // run() может позвать фасад без причины или с другой (ревью №1 точки А плана 1.1)
310
+ if (ctx.batch && op.reason === undefined && ctx.reason !== undefined)
311
+ op = { ...op, reason: ctx.reason };
286
312
  const next = last.op
287
313
  ? [...steps, { name: last.name, cls: last.cls, role: last.role, aliasKey: last.aliasKey, self: true, op, opMods: mods, nodeKey: nextNodeKey() }]
288
314
  : [...steps.slice(0, -1), { ...last, op, opMods: mods }];
@@ -364,6 +390,29 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
364
390
  const m = opts?.follow ? { ...mods, follow: true } : mods;
365
391
  return (await read(ctx, steps, m, 'versions')).res.map((r) => toRow(r.row));
366
392
  };
393
+ case 'diff':
394
+ return async (opts = {}) => {
395
+ warnNoHistory(steps, 'diff');
396
+ const hist = {
397
+ ...(opts.from !== undefined ? { from: versionRef(opts.from, 'diff({ from })') } : {}),
398
+ ...(opts.to !== undefined ? { to: versionRef(opts.to, 'diff({ to })') } : {}),
399
+ ...(opts.follow ? { follow: true } : {}),
400
+ };
401
+ return (await read(ctx, steps, { ...mods, hist }, 'diff')).res.map((r) => r.row);
402
+ };
403
+ case 'blame':
404
+ return async (opts = {}) => {
405
+ warnNoHistory(steps, 'blame');
406
+ if (opts.limit !== undefined)
407
+ versionRef(opts.limit, 'blame({ limit })', false);
408
+ const hist = {
409
+ ...(opts.at !== undefined ? { at: momentOf(opts.at, 'blame({ at })') } : {}),
410
+ ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
411
+ };
412
+ // null — объекта на момент at не было (или он не виден)
413
+ return (await read(ctx, steps, { ...mods, hist }, 'blame')).res
414
+ .map((r) => r.row).filter((x) => x !== null);
415
+ };
367
416
  case 'sum':
368
417
  case 'avg':
369
418
  case 'min':
@@ -404,17 +453,17 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
404
453
  throw removed('account()', 'арендатор сессии и auth.switch() (этап 5)');
405
454
  // глаголы записи — звенья плана (этап 4)
406
455
  case 'create':
407
- return (data = {}) => withOp({ kind: 'create', data });
456
+ return (data = {}, opts) => withOp({ kind: 'create', data, ...reasonOf(opts) });
408
457
  case 'update':
409
- return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}) });
458
+ return (data = {}, opts) => withOp({ kind: 'update', data, ...(opts?.rev !== undefined ? { rev: opts.rev } : {}), ...reasonOf(opts) });
410
459
  case 'upsert':
411
- return (data = {}) => withOp({ kind: 'upsert', data });
460
+ return (data = {}, opts) => withOp({ kind: 'upsert', data, ...reasonOf(opts) });
412
461
  case 'delete':
413
- return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true });
462
+ return (opts) => withOp({ kind: 'delete', confirm: opts?.confirm === true, ...reasonOf(opts) });
414
463
  case 'anonymize':
415
- return (fields) => withOp({ kind: 'anonymize', fields });
464
+ return (fields, opts) => withOp({ kind: 'anonymize', fields, ...reasonOf(opts) });
416
465
  case 'reclass':
417
- return (cls, data) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}) });
466
+ return (cls, data, opts) => withOp({ kind: 'reclass', cls, ...(data !== undefined ? { data } : {}), ...reasonOf(opts) });
418
467
  case 'set':
419
468
  case 'unset':
420
469
  case 'add':
@@ -423,11 +472,11 @@ export function makeChain(ctx, steps, mods, nodes = NO_NODES) {
423
472
  case 'forUpdate':
424
473
  return () => withMods({ forUpdate: true });
425
474
  case 'restore':
426
- return () => withOp({ kind: 'restore' });
475
+ return (opts) => withOp({ kind: 'restore', ...reasonOf(opts) });
427
476
  case 'purge':
428
- return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true });
477
+ return (opts) => withOp({ kind: 'purge', confirm: opts?.confirm === true, ...reasonOf(opts) });
429
478
  case 'rekey':
430
- return (patch) => withOp({ kind: 'rekey', data: patch ?? {} });
479
+ return (patch, opts) => withOp({ kind: 'rekey', data: patch ?? {}, ...reasonOf(opts) });
431
480
  case 'cache':
432
481
  return (opts) => {
433
482
  if (opts?.ttl !== undefined && !(opts.ttl > 0))
@@ -459,7 +508,7 @@ export function makeBatch(ctx, queue) {
459
508
  precheckPlan(ctx.reg, p);
460
509
  if (!plans.length)
461
510
  return [];
462
- return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner }, plans));
511
+ return ctx.exec.run((q) => execBatch({ reg: ctx.reg, schema: ctx.schema, q, tenant: ctx.tenant(), owner: ctx.owner, ...reasons(ctx) }, plans));
463
512
  };
464
513
  case 'execute':
465
514
  throw removed('batch.execute()', 'batch.run()');
package/dist/cli.js CHANGED
@@ -6,12 +6,13 @@
6
6
  * letopis sync <модуль моделей> --dsn … --schema v2.x (--token … | --api-key …) [--apply]
7
7
  * letopis types --dsn … --schema v2.x (--token … | --api-key …) [--out types.d.ts]
8
8
  * letopis load <файлы NDJSON…> --dsn … --schema v2.x (--token … | --api-key …) [--class C]
9
- * [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify]
9
+ * [--duplicates error|first|last] [--existing error|skip|update] [--commit-every N] [--verify] [--initial]
10
10
  * letopis verify --dsn … --schema v2.x [--token … | --api-key …] [--data] — код 1 при находках
11
11
  * letopis maintain --dsn … --schema v2.x [--token … | --api-key …] [--batches N]
12
12
  * letopis describe --dsn … --schema v2.x (--token … | --api-key …) [--format json|text] [--out файл]
13
13
  * letopis import --from <строка 0.21> --from-schema v1.x --dsn … --schema v2.x [--tenant <имя>] [--keyless C]…
14
- * [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] — код 1, если verify нашёл расхождения
14
+ * [--rename Старое=Новое]… [--update] [--commit-every N] [--partition p] [--initial] — код 1, если verify нашёл расхождения
15
+ * --initial — первая заливка (план 1.1, §2.3): пустая схема, одна транзакция, индексы и триггеры — после загрузки
15
16
  */
16
17
  import { parseArgs } from 'node:util';
17
18
  import { readFile, writeFile } from 'node:fs/promises';
@@ -24,12 +25,12 @@ import { import021 } from './import.js';
24
25
  const USAGE = `letopis sync <модуль> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--apply]
25
26
  letopis types --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--out <файл>]
26
27
  letopis load <файлы…> --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--class <Класс>]
27
- [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify]
28
+ [--duplicates error|first|last] [--existing error|skip|update] [--commit-every <N>] [--verify] [--initial]
28
29
  letopis verify --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--data]
29
30
  letopis maintain --dsn <строка> --schema <v2.имя> [--token <токен> | --api-key <ключ>] [--batches <N>]
30
31
  letopis describe --dsn <строка> --schema <v2.имя> (--token <токен> | --api-key <ключ>) [--format json|text] [--out <файл>]
31
32
  letopis import --from <строка 0.21> --from-schema <v1.имя> --dsn <строка> --schema <v2.имя> [--tenant <имя>] [--keyless <Класс>]…
32
- [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>]`;
33
+ [--rename <Старое>=<Новое>]… [--update] [--commit-every <N>] [--partition <имя>] [--initial]`;
33
34
  const isModel = (v) => typeof v === 'object' && v !== null && typeof v.describe === 'function' && typeof v.name === 'string';
34
35
  export async function main(argv) {
35
36
  const { values, positionals } = parseArgs({
@@ -43,6 +44,7 @@ export async function main(argv) {
43
44
  format: { type: 'string' },
44
45
  from: { type: 'string' }, 'from-schema': { type: 'string' }, tenant: { type: 'string' }, partition: { type: 'string' },
45
46
  keyless: { type: 'string', multiple: true }, rename: { type: 'string', multiple: true }, update: { type: 'boolean' },
47
+ initial: { type: 'boolean' },
46
48
  },
47
49
  });
48
50
  const [cmd, arg] = positionals;
@@ -68,7 +70,7 @@ export async function main(argv) {
68
70
  const r = await import021({
69
71
  from: values.from, fromSchema: values['from-schema'], dsn: values.dsn, schema: values.schema, tenant: values.tenant,
70
72
  partition: values.partition, keyless: values.keyless, rename, update: values.update,
71
- commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
73
+ commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined, initial: values.initial,
72
74
  });
73
75
  console.log(JSON.stringify(r, null, 2));
74
76
  return r.verify.ok ? 0 : 1;
@@ -116,6 +118,7 @@ export async function main(argv) {
116
118
  existing: values.existing,
117
119
  commitEvery: values['commit-every'] ? Number(values['commit-every']) : undefined,
118
120
  verify: values.verify,
121
+ initial: values.initial,
119
122
  });
120
123
  console.log(JSON.stringify(r));
121
124
  return 0;
package/dist/import.d.ts CHANGED
@@ -46,6 +46,12 @@ export interface ImportOptions {
46
46
  update?: boolean;
47
47
  /** Порции загрузчика (§2.6, п. 8). */
48
48
  commitEvery?: number;
49
+ /**
50
+ * Первая заливка (план 1.1, §2.3): весь импорт — одной транзакцией в пустую схему, индексы и триггеры —
51
+ * после загрузки; несовместимо с update и commitEvery. Строки, уже лежащие в схеме (системный сид:
52
+ * ресурсы, правила), пропускаются, как без флага.
53
+ */
54
+ initial?: boolean;
49
55
  /** Без сообщений в консоль. */
50
56
  quiet?: boolean;
51
57
  }