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/import.js CHANGED
@@ -26,6 +26,7 @@ import { normalizeSchema } from './jsonschema.js';
26
26
  import { RESERVED_CLASS_NAMES } from './types.js';
27
27
  import { base32Decode } from './auth.js';
28
28
  import { v5Id } from './uuid.js';
29
+ import { initialSession } from './load.js';
29
30
  const SYSTEM_ID = '2eba6d0a-1edd-4bb6-a85a-a703dab49035';
30
31
  const SYSTEM_CLASSES = new Set(['Class', 'Account', 'member', 'Resource', 'rule']);
31
32
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -427,6 +428,9 @@ const quote = (s) => `"${s.replace(/"/g, '""')}"`;
427
428
  export async function import021(opts) {
428
429
  const t0 = performance.now();
429
430
  const log = opts.quiet ? () => { } : (m) => console.log(`letopis.import: ${m}`);
431
+ if (opts.initial && (opts.update || opts.commitEvery)) {
432
+ throw new LetopisError('invalid_data', 'import --initial несовместим с --update и --commit-every: первая заливка — одна транзакция в пустую схему');
433
+ }
430
434
  const partition = opts.partition ?? 'entity';
431
435
  const owner = opts.owner ?? 'letopis_owner';
432
436
  const src = typeof opts.from === 'string' ? postgres(opts.from, { max: 2, onnotice: () => { } }) : opts.from;
@@ -557,195 +561,212 @@ export async function import021(opts) {
557
561
  resources: { loaded: 0, skipped: 0 }, rules: { loaded: 0, skipped: 0 }, objects: { loaded: 0, skipped: 0, updated: 0 },
558
562
  uuidFields: {}, droppedFields: {}, ownerLost: 0, verify: { ok: false, issues: 0, anchor: '' }, ms: 0,
559
563
  };
560
- // --- шаг 1: классы (System, от владельца), предки раньше потомков ---
561
- await dst.begin(async (tx) => {
562
- await tx.unsafe(`set local role ${quote(owner)}`);
563
- for (const r of schemaRows) {
564
- const c = b.classes.get(r.id);
565
- if (!c)
566
- continue;
567
- const [ex] = await tx.unsafe(`select 1 from ${S}.entity where class = 'Class' and data->>'name' = $1`, [c.name]);
568
- if (ex) {
569
- report.classes.existing.push(c.name);
570
- continue;
571
- }
572
- await tx.unsafe(`select ${S}.sys_create(${S}.v5_id($1::uuid, 'Class', array[$2]), 'Class', $1::uuid, '{}', $3::jsonb)`, [SYSTEM_ID, c.name, c.desc]).catch((e) => {
573
- e.message = `import: класс ${c.name}: ${e.message}`;
574
- throw e;
575
- });
576
- report.classes.created.push(c.name);
577
- }
578
- await tx.unsafe(`select pg_notify($1, 'Class')`, [opts.schema]);
579
- });
580
- log(`классы: создано ${report.classes.created.length}, уже были ${report.classes.existing.length}, опущено ${b.omitted.length}`);
581
- // --- шаг 2: аккаунты (System) и арендатор данных System ---
582
- const accRows = [];
583
- for (const a of accounts) {
584
- if (systemOld.has(a.id))
585
- continue;
586
- const data = (a.data ?? {});
587
- const name = typeof data.name === 'string' ? data.name : typeof data.id === 'string' ? data.id : a.id;
588
- accRows.push({ $class: 'Account', $ext: a.id, name, categories: a.categories.filter((c) => c !== 'System'), enabled: a.enabled !== false });
589
- }
590
- if (sysTenant)
591
- accRows.push({ $class: 'Account', $ext: `tenant:${opts.tenant}`, name: opts.tenant, categories: [], enabled: true });
592
- if (accRows.length) {
593
- const r = await db.load(accRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
594
- report.accounts = { loaded: r.loaded, skipped: r.skipped };
595
- }
596
- // --- шаг 3: креды → secret (от владельца; хэши как есть); совпадение без учёта регистра — первым идёт
597
- // подтверждённый, затем более ранний, остальные — в отчёт ---
598
- const creds = await src.unsafe(`select account::text as account, category, identifier, meta, confirmed from ${F}."Credential" where deleted is null order by confirmed desc, created, id`);
599
- const secretRows = [];
600
- for (const c of creds) {
601
- const m = (c.meta ?? {});
602
- const account = accId(c.account);
603
- const kind = String(c.category);
604
- const confirmed = c.confirmed !== false;
605
- if (kind === 'OTP' || kind.startsWith('OTP_') || typeof m.code === 'string') {
606
- report.credentials.otp++;
607
- continue;
608
- }
609
- if (typeof m.password === 'string')
610
- secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(m.password, 'utf8'), meta: { confirmed } });
611
- else if (kind === 'APIKEY')
612
- secretRows.push({ account, kind, ident: String(m.prefix ?? ''), material: Buffer.from(String(c.identifier), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
613
- else if (kind === 'KEYSECRET')
614
- secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(String(m.secret ?? ''), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
615
- else if (kind === 'TOTP')
616
- secretRows.push({ account, kind, ident: account, material: base32Decode(String(m.secret ?? '')), meta: { confirmed, last_step: Number(m.lastStep ?? 0) } });
617
- else
618
- secretRows.push({ account, kind, ident: c.identifier, material: null, meta: { ...m, confirmed } });
619
- }
620
- if (secretRows.length) {
621
- await dst.begin(async (tx) => {
622
- await tx.unsafe(`set local role ${quote(owner)}`);
623
- for (const s of secretRows) {
624
- const [dup] = await tx.unsafe(`select account::text as account from ${S}.secret
625
- where kind = $1 and casefold(ident) collate pg_unicode_fast = casefold($2) collate pg_unicode_fast and deleted_at is null`, [s.kind, s.ident]);
626
- if (dup) {
627
- if (dup.account === s.account)
628
- report.credentials.skipped++;
629
- else
630
- report.credentials.conflicts.push({ account: s.account, kind: s.kind, ident: s.ident });
564
+ const steps = async (x) => {
565
+ // --- шаг 1: классы (System, от владельца), предки раньше потомков ---
566
+ await x.begin(async (tx) => {
567
+ for (const r of schemaRows) {
568
+ const c = b.classes.get(r.id);
569
+ if (!c)
570
+ continue;
571
+ const [ex] = await tx.unsafe(`select 1 from ${S}.entity where class = 'Class' and data->>'name' = $1`, [c.name]);
572
+ if (ex) {
573
+ report.classes.existing.push(c.name);
631
574
  continue;
632
575
  }
633
- await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta) values ($1::uuid, $2, $3, $4, $5::jsonb)`, [s.account, s.kind, s.ident, s.material, s.meta]);
634
- report.credentials.imported++;
576
+ await tx.unsafe(`select ${S}.sys_create(${S}.v5_id($1::uuid, 'Class', array[$2]), 'Class', $1::uuid, '{}', $3::jsonb)`, [SYSTEM_ID, c.name, c.desc]).catch((e) => {
577
+ e.message = `import: класс ${c.name}: ${e.message}`;
578
+ throw e;
579
+ });
580
+ report.classes.created.push(c.name);
635
581
  }
582
+ // сигнал — функцией базы: нагрузка по режиму сигналов установки (план 1.1)
583
+ await tx.unsafe(`select ${S}.signal(array['Class'], array[$1::uuid])`, [SYSTEM_ID]);
636
584
  });
637
- }
638
- // --- шаг 4: права — Resource и rule в System ---
639
- const resources = await src.unsafe(`select alias, category, pattern, meta from ${F}."Resource" order by alias`);
640
- const resRows = resources.map((r) => {
641
- let pattern = r.pattern;
642
- if (isObj(pattern) && 'account' in pattern) {
643
- const { account, ...rest } = pattern;
644
- pattern = { ...rest, tenant: account };
585
+ log(`классы: создано ${report.classes.created.length}, уже были ${report.classes.existing.length}, опущено ${b.omitted.length}`);
586
+ // --- шаг 2: аккаунты (System) и арендатор данных System ---
587
+ const accRows = [];
588
+ for (const a of accounts) {
589
+ if (systemOld.has(a.id))
590
+ continue;
591
+ const data = (a.data ?? {});
592
+ const name = typeof data.name === 'string' ? data.name : typeof data.id === 'string' ? data.id : a.id;
593
+ accRows.push({ $class: 'Account', $ext: a.id, name, categories: a.categories.filter((c) => c !== 'System'), enabled: a.enabled !== false });
645
594
  }
646
- const meta = (r.meta ?? {});
647
- return { $class: 'Resource', alias: r.alias, category: r.category, ...(isObj(pattern) ? { pattern } : {}),
648
- ...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
649
- });
650
- if (resRows.length) {
651
- const r = await db.load(resRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
652
- report.resources = { loaded: r.loaded, skipped: r.skipped };
653
- }
654
- const rules = await src.unsafe(`select account, resource, permission, weight, meta, enabled from ${F}."Rule" order by account, resource`);
655
- const ruleRows = rules.map((r) => {
656
- const meta = (r.meta ?? {});
657
- const code = typeof meta.code === 'number' ? meta.code : typeof meta.code === 'string' && /^\d+$/.test(meta.code) ? Number(meta.code) : undefined;
658
- return { $class: 'rule', Group: { $key: [r.account] }, Object: { $key: [r.resource] }, permission: r.permission, weight: r.weight ?? 0,
659
- enabled: r.enabled !== false, ...(code !== undefined ? { code } : {}), ...(typeof meta.message === 'string' ? { message: meta.message } : {}),
660
- ...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
661
- });
662
- if (ruleRows.length) {
663
- const r = await db.load(ruleRows, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' });
664
- report.rules = { loaded: r.loaded, skipped: r.skipped };
665
- }
666
- // --- шаг 5: данные по арендаторам — загрузчик в режиме истории ---
667
- const accountIds = new Set([SYSTEM_ID, ...accounts.map((a) => accId(a.id)), ...(sysTenant ? [sysTenant] : [])]);
668
- const oldIds = new Set([...objs.values()].map((o) => o.id));
669
- const byTenant = new Map();
670
- for (const o of objs.values()) {
671
- const t = tenantOf(o.account);
672
- if (!byTenant.has(t))
673
- byTenant.set(t, []);
674
- byTenant.get(t).push(o);
675
- }
676
- for (const [tenant, list] of byTenant) {
677
- const old = list[0].account;
678
- report.tenants.push({ old, id: tenant, name: systemOld.has(old) ? opts.tenant : old });
679
- const rows = [];
680
- const origin = [];
681
- const lastRows = [];
682
- for (const o of list) {
683
- const c = b.classes.get(o.cls);
684
- const keyed = b.keyOf(o.cls).length > 0;
685
- const src21 = report.source[c.name] ?? (report.source[c.name] = { objects: 0, versions: 0, live: 0 });
686
- src21.objects++;
687
- src21.versions += o.versions.length;
688
- if (!o.versions[o.versions.length - 1].del)
689
- src21.live++;
690
- let afterDelete = false;
691
- o.versions.forEach((v, k) => {
692
- const data = {};
693
- for (const [f, x] of Object.entries(v.data)) {
694
- if (f === 'id')
695
- continue;
696
- if (f.startsWith('$')) {
697
- report.droppedFields[`${c.name}.${f}`] = (report.droppedFields[`${c.name}.${f}`] ?? 0) + 1;
595
+ if (sysTenant)
596
+ accRows.push({ $class: 'Account', $ext: `tenant:${opts.tenant}`, name: opts.tenant, categories: [], enabled: true });
597
+ if (accRows.length) {
598
+ const fresh = accRows.filter((row) => !x.taken(v5Id(SYSTEM_ID, 'Account', [String(row.$ext)])));
599
+ const r = fresh.length ? await x.load(fresh, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' }) : null;
600
+ report.accounts = { loaded: r?.loaded ?? 0, skipped: (r?.skipped ?? 0) + accRows.length - fresh.length };
601
+ }
602
+ // --- шаг 3: креды → secret (от владельца; хэши как есть); совпадение без учёта регистра — первым идёт
603
+ // подтверждённый, затем более ранний, остальные — в отчёт ---
604
+ const creds = await src.unsafe(`select account::text as account, category, identifier, meta, confirmed from ${F}."Credential" where deleted is null order by confirmed desc, created, id`);
605
+ const secretRows = [];
606
+ for (const c of creds) {
607
+ const m = (c.meta ?? {});
608
+ const account = accId(c.account);
609
+ const kind = String(c.category);
610
+ const confirmed = c.confirmed !== false;
611
+ if (kind === 'OTP' || kind.startsWith('OTP_') || typeof m.code === 'string') {
612
+ report.credentials.otp++;
613
+ continue;
614
+ }
615
+ if (typeof m.password === 'string')
616
+ secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(m.password, 'utf8'), meta: { confirmed } });
617
+ else if (kind === 'APIKEY')
618
+ secretRows.push({ account, kind, ident: String(m.prefix ?? ''), material: Buffer.from(String(c.identifier), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
619
+ else if (kind === 'KEYSECRET')
620
+ secretRows.push({ account, kind, ident: c.identifier, material: Buffer.from(String(m.secret ?? ''), 'hex'), meta: { ...(m.name ? { name: m.name } : {}), confirmed: true } });
621
+ else if (kind === 'TOTP')
622
+ secretRows.push({ account, kind, ident: account, material: base32Decode(String(m.secret ?? '')), meta: { confirmed, last_step: Number(m.lastStep ?? 0) } });
623
+ else
624
+ secretRows.push({ account, kind, ident: c.identifier, material: null, meta: { ...m, confirmed } });
625
+ }
626
+ if (secretRows.length) {
627
+ await x.begin(async (tx) => {
628
+ for (const s of secretRows) {
629
+ const [dup] = await tx.unsafe(`select account::text as account from ${S}.secret
630
+ where kind = $1 and casefold(ident) collate pg_unicode_fast = casefold($2) collate pg_unicode_fast and deleted_at is null`, [s.kind, s.ident]);
631
+ if (dup) {
632
+ if (dup.account === s.account)
633
+ report.credentials.skipped++;
634
+ else
635
+ report.credentials.conflicts.push({ account: s.account, kind: s.kind, ident: s.ident });
698
636
  continue;
699
637
  }
700
- data[f] = x;
701
- }
702
- const links = {};
703
- for (const [k21, target] of Object.entries(v.links)) {
704
- const role = c.roleOf.get(k21) ?? b.classes.get(k21)?.name ?? k21;
705
- links[role] = newId(k21, target) ?? v5Id(tenant, b.classes.get(k21)?.name ?? k21, [`missing:${target}`]);
706
- }
707
- let ownerId = tenant;
708
- if (v.owner && v.owner !== o.account) {
709
- const mapped = systemOld.has(v.owner) ? SYSTEM_ID : accId(v.owner);
710
- if (accountIds.has(mapped))
711
- ownerId = mapped;
712
- else if (k === o.versions.length - 1)
713
- report.ownerLost++;
714
- }
715
- const op = v.del ? 'delete' : k === 0 || afterDelete ? 'create' : 'update';
716
- afterDelete = v.del;
717
- const row = { $class: c.name, $rev: k + 1, $at: v.at, $op: op, ...(keyed ? {} : { $ext: o.id }),
718
- ...(v.tags.length ? { $tags: v.tags } : {}), ...(ownerId !== tenant ? { $owner: ownerId } : {}), ...data, ...links };
719
- rows.push(row);
720
- origin.push({ class: o.cls, id: o.id, rev: k + 1 });
721
- if (k === o.versions.length - 1 && !v.del) {
722
- const { $rev: _r, $at: _a, $op: _o, ...cur } = row;
723
- lastRows.push(cur);
724
- scanUuids(data, `${c.name}`, oldIds, report.uuidFields);
638
+ await tx.unsafe(`insert into ${S}.secret (account, kind, ident, material, meta) values ($1::uuid, $2, $3, $4, $5::jsonb)`, [s.account, s.kind, s.ident, s.material, s.meta]);
639
+ report.credentials.imported++;
725
640
  }
726
641
  });
727
642
  }
728
- try {
729
- const r = await db.load(rows, { tenant, withHistory: true, existing: 'skip', agent: 'letopis import', ...(opts.commitEvery ? { commitEvery: opts.commitEvery } : {}) });
730
- report.objects.loaded += r.loaded;
731
- report.objects.skipped += r.skipped;
732
- if (opts.update && lastRows.length) {
733
- const u = await db.load(lastRows, { tenant, existing: 'update', agent: 'letopis import' });
734
- report.objects.updated += u.updated;
643
+ // --- шаг 4: права — Resource и rule в System ---
644
+ const resources = await src.unsafe(`select alias, category, pattern, meta from ${F}."Resource" order by alias`);
645
+ const resRows = resources.map((r) => {
646
+ let pattern = r.pattern;
647
+ if (isObj(pattern) && 'account' in pattern) {
648
+ const { account, ...rest } = pattern;
649
+ pattern = { ...rest, tenant: account };
735
650
  }
651
+ const meta = (r.meta ?? {});
652
+ return { $class: 'Resource', alias: r.alias, category: r.category, ...(isObj(pattern) ? { pattern } : {}),
653
+ ...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
654
+ });
655
+ if (resRows.length) {
656
+ const fresh = resRows.filter((row) => !x.taken(v5Id(SYSTEM_ID, 'Resource', [String(row.alias)])));
657
+ const r = fresh.length ? await x.load(fresh, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' }) : null;
658
+ report.resources = { loaded: r?.loaded ?? 0, skipped: (r?.skipped ?? 0) + resRows.length - fresh.length };
736
659
  }
737
- catch (e) {
738
- // строки загрузчика → объекты 0.21; total, truncated, limit и complete отчёта загрузчика — как есть
739
- const err = e;
740
- const detail = err.detail;
741
- if (detail?.rows) {
742
- const mapped = detail.rows.map((x) => ({ ...origin[x.line - 1], code: x.code, message: x.message }));
743
- throw new LetopisError(err.code ?? 'invalid_data', `import: арендатор ${tenant}: ${err.message}`, { ...detail, rows: mapped, notes: b.notes });
660
+ const rules = await src.unsafe(`select account, resource, permission, weight, meta, enabled from ${F}."Rule" order by account, resource`);
661
+ const ruleRows = rules.map((r) => {
662
+ const meta = (r.meta ?? {});
663
+ const code = typeof meta.code === 'number' ? meta.code : typeof meta.code === 'string' && /^\d+$/.test(meta.code) ? Number(meta.code) : undefined;
664
+ return { $class: 'rule', Group: { $key: [r.account] }, Object: { $key: [r.resource] }, permission: r.permission, weight: r.weight ?? 0,
665
+ enabled: r.enabled !== false, ...(code !== undefined ? { code } : {}), ...(typeof meta.message === 'string' ? { message: meta.message } : {}),
666
+ ...(typeof meta.description === 'string' ? { description: meta.description } : {}) };
667
+ });
668
+ if (ruleRows.length) {
669
+ const res = (alias) => v5Id(SYSTEM_ID, 'Resource', [String(alias)]);
670
+ const fresh = ruleRows.filter((row) => !x.taken(v5Id(SYSTEM_ID, 'rule', [res(row.Group.$key[0]), res(row.Object.$key[0])])));
671
+ const r = fresh.length ? await x.load(fresh, { tenant: SYSTEM_ID, existing: 'skip', agent: 'letopis import' }) : null;
672
+ report.rules = { loaded: r?.loaded ?? 0, skipped: (r?.skipped ?? 0) + ruleRows.length - fresh.length };
673
+ }
674
+ // --- шаг 5: данные по арендаторам — загрузчик в режиме истории ---
675
+ const accountIds = new Set([SYSTEM_ID, ...accounts.map((a) => accId(a.id)), ...(sysTenant ? [sysTenant] : [])]);
676
+ const oldIds = new Set([...objs.values()].map((o) => o.id));
677
+ const byTenant = new Map();
678
+ for (const o of objs.values()) {
679
+ const t = tenantOf(o.account);
680
+ if (!byTenant.has(t))
681
+ byTenant.set(t, []);
682
+ byTenant.get(t).push(o);
683
+ }
684
+ for (const [tenant, list] of byTenant) {
685
+ const old = list[0].account;
686
+ report.tenants.push({ old, id: tenant, name: systemOld.has(old) ? opts.tenant : old });
687
+ const rows = [];
688
+ const origin = [];
689
+ const lastRows = [];
690
+ for (const o of list) {
691
+ const c = b.classes.get(o.cls);
692
+ const keyed = b.keyOf(o.cls).length > 0;
693
+ const src21 = report.source[c.name] ?? (report.source[c.name] = { objects: 0, versions: 0, live: 0 });
694
+ src21.objects++;
695
+ src21.versions += o.versions.length;
696
+ if (!o.versions[o.versions.length - 1].del)
697
+ src21.live++;
698
+ let afterDelete = false;
699
+ o.versions.forEach((v, k) => {
700
+ const data = {};
701
+ for (const [f, x] of Object.entries(v.data)) {
702
+ if (f === 'id')
703
+ continue;
704
+ if (f.startsWith('$')) {
705
+ report.droppedFields[`${c.name}.${f}`] = (report.droppedFields[`${c.name}.${f}`] ?? 0) + 1;
706
+ continue;
707
+ }
708
+ data[f] = x;
709
+ }
710
+ const links = {};
711
+ for (const [k21, target] of Object.entries(v.links)) {
712
+ const role = c.roleOf.get(k21) ?? b.classes.get(k21)?.name ?? k21;
713
+ links[role] = newId(k21, target) ?? v5Id(tenant, b.classes.get(k21)?.name ?? k21, [`missing:${target}`]);
714
+ }
715
+ let ownerId = tenant;
716
+ if (v.owner && v.owner !== o.account) {
717
+ const mapped = systemOld.has(v.owner) ? SYSTEM_ID : accId(v.owner);
718
+ if (accountIds.has(mapped))
719
+ ownerId = mapped;
720
+ else if (k === o.versions.length - 1)
721
+ report.ownerLost++;
722
+ }
723
+ const op = v.del ? 'delete' : k === 0 || afterDelete ? 'create' : 'update';
724
+ afterDelete = v.del;
725
+ const row = { $class: c.name, $rev: k + 1, $at: v.at, $op: op, ...(keyed ? {} : { $ext: o.id }),
726
+ ...(v.tags.length ? { $tags: v.tags } : {}), ...(ownerId !== tenant ? { $owner: ownerId } : {}), ...data, ...links };
727
+ rows.push(row);
728
+ origin.push({ class: o.cls, id: o.id, rev: k + 1 });
729
+ if (k === o.versions.length - 1 && !v.del) {
730
+ const { $rev: _r, $at: _a, $op: _o, ...cur } = row;
731
+ lastRows.push(cur);
732
+ scanUuids(data, `${c.name}`, oldIds, report.uuidFields);
733
+ }
734
+ });
735
+ }
736
+ try {
737
+ const r = await x.load(rows, { tenant, withHistory: true, existing: 'skip', agent: 'letopis import', ...(opts.commitEvery ? { commitEvery: opts.commitEvery } : {}) });
738
+ report.objects.loaded += r.loaded;
739
+ report.objects.skipped += r.skipped;
740
+ if (opts.update && lastRows.length) {
741
+ const u = await x.load(lastRows, { tenant, existing: 'update', agent: 'letopis import' });
742
+ report.objects.updated += u.updated;
743
+ }
744
744
  }
745
- throw e;
745
+ catch (e) {
746
+ // строки загрузчика → объекты 0.21; total, truncated, limit и complete отчёта загрузчика — как есть
747
+ const err = e;
748
+ const detail = err.detail;
749
+ if (detail?.rows) {
750
+ const mapped = detail.rows.map((x) => ({ ...origin[x.line - 1], code: x.code, message: x.message }));
751
+ throw new LetopisError(err.code ?? 'invalid_data', `import: арендатор ${tenant}: ${err.message}`, { ...detail, rows: mapped, notes: b.notes });
752
+ }
753
+ throw e;
754
+ }
755
+ log(`арендатор ${tenant}: объектов ${list.length}, версий ${rows.length}`);
746
756
  }
747
- log(`арендатор ${tenant}: объектов ${list.length}, версий ${rows.length}`);
748
- }
757
+ return byTenant;
758
+ };
759
+ const byTenant = opts.initial
760
+ // existing в первой заливке — только 'error': строки сида отфильтрованы taken(), прочих объектов в схеме нет
761
+ ? await initialSession({ sql: dst, schema: opts.schema, tenant: null, admin: true, ownerRole: owner }, (sess) => steps({ begin: (fn) => fn(sess.sql), load: (rows, o) => sess.load(rows, { ...o, existing: undefined }), taken: (id) => sess.taken(id) }))
762
+ : await steps({
763
+ begin: (fn) => dst.begin(async (tx) => {
764
+ await tx.unsafe(`set local role ${quote(owner)}`);
765
+ return fn(tx);
766
+ }),
767
+ load: (rows, o) => db.load(rows, o),
768
+ taken: () => false,
769
+ });
749
770
  // --- шаг 6: проверка — счётчики и verify() ---
750
771
  const tenants = [...byTenant.keys()];
751
772
  if (tenants.length) {
package/dist/index.d.ts CHANGED
@@ -34,7 +34,9 @@ export type { LoadOptions, LoadReport, LoadIssue, LoadInput } from './load.js';
34
34
  export type { Chain, ChainCore, StepFn, BatchEntry } from './chain.js';
35
35
  export type { ModelLike, AnyModelLike, RowOf, FilterOf, FieldFilterOf, TypedChain, StepsOf, PatchOf } from './typed.js';
36
36
  export type { Row, Path, Filter, FieldFilter, Cursor, ChainMods, QueryEvent, Op, Scalar } from './types.js';
37
+ export type { DiffChange, DiffResult, DiffOptions, BlameField, BlameResult, BlameOptions } from './types.js';
37
38
  export type { CacheOptions, CacheStats } from './cache.js';
39
+ export type { SignalMode } from './signals.js';
38
40
  export type { WatchOptions, WatchEvent, Subscription } from './watch.js';
39
41
  export type { VerifyResult, VerifyIssue, MaintainOptions, MaintainReport, ResetOptions, ResetResult, ModelDescription } from './admin.js';
40
42
  export { describeText } from './admin.js';
@@ -73,7 +75,8 @@ export interface ConnectOptions {
73
75
  /**
74
76
  * Слушать сигналы базы (LISTEN, по умолчанию да). false — пул в режиме транзакций (PgBouncer):
75
77
  * реестр перечитывается через db.refresh(), подписка опрашивает журнал, кэш результатов выключен
76
- * (с cache: { ttlOnly: true } — работает только по сроку).
78
+ * (с cache: { ttlOnly: true } — работает только по сроку). В установке с up({ signals: 'none' })
79
+ * слушателя нет всегда, а реестр раз в 5 с сверяет отпечаток классов.
77
80
  */
78
81
  listen?: boolean;
79
82
  /** Кэш результатов (§2.12): срок по умолчанию, размер, ttlOnly; false — выключен. */
@@ -139,7 +142,10 @@ export interface DbCore {
139
142
  tenant?: string;
140
143
  }): Promise<Db>;
141
144
  /** Явная транзакция read committed: те же цепочки, commit(), rollback(), lock(), forUpdate(). */
142
- begin(): Promise<DbTx>;
145
+ /** Явная транзакция; reason — причина изменения для всех операций блока (план 1.1). */
146
+ begin(opts?: {
147
+ reason?: string;
148
+ }): Promise<DbTx>;
143
149
  /**
144
150
  * Загрузчик (§2.6): строки любых классов — проверки в коде, COPY от роли владельца, один запрос по
145
151
  * целям вне загрузки. Только на администраторском подключении (иначе admin_required).
@@ -149,7 +155,9 @@ export interface DbCore {
149
155
  * Именованная очередь планов фасада: batch.Класс(…).create(…); batch.run() — одной транзакцией. У корня,
150
156
  * у каждого db.as() и у каждой db.begin() очереди свои: план исполняется от имени фасада, который его поставил.
151
157
  */
152
- batch(name: string): Batch;
158
+ batch(name: string, opts?: {
159
+ reason?: string;
160
+ }): Batch;
153
161
  /** Advisory-блокировка до конца транзакции — только в db.begin() (иначе tx_required). */
154
162
  lock(...keys: (string | number)[]): Promise<void>;
155
163
  /** Зафиксировать транзакцию tr (то же, что tr.commit()). */