[@ant] feat(migrator): per-coop self-service миграции agreements + L3 (story 2.5/3.6/3.7)
Три миграции через ledger3-релиз rollout:
- 047_migrate_agreements_to_wallet.ts — Эпик 2 / story 2.5
agreements3.program_id>0 (status=confirmed) → wallet::migrate3.
Идемпотентно, без удаления исходных записей (Эпик 4 финального
deploy soviet).
- 048_migrate_phase1_minshare.ts — Эпик 3 / story 3.6 (ADR-008)
participants[status=accepted] → ledger2::migrate3 на w.reg.minshr.
amount = coop.org_minimum для type=organization, иначе coop.minimum.
w.reg.entry не затрагивается — он COOPERATIVE без L3-разбивки.
- 049_migrate_phase2_progwallets.ts — Эпик 3 / story 3.7
progwallets → ledger2::migrate3 с маппингом program_id → wallet_name:
1 (ЦК) → w.wal.share (membership_contribution=0 на prod)
3 (Генератор) → w.cap.gen
4 (Благорост) → w.cap.blago
Pre-flight gate (NFR18) выполняется внешним скриптом сравнения
Σ progwallets vs Σ userwallets перед production rollout.
src/utils:
- listCoops — обход registrator::coops с фильтром is_cooperative+active
- fetchAllRows — пагинированное чтение get_table_rows с lower_bound
Все три миграции per-coop self-service, batched по 50 actions/tx,
идемпотентные (migrate3 — overwrite, не +=).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Миграция программных соглашений (program_id > 0) из soviet::agreements3
|
||||
* в wallet::users (Эпик 2 / story 2.5; ADR-008).
|
||||
*
|
||||
* Per-coop self-service: для каждого coop из registrator::cooperatives2 читает
|
||||
* agreements3 и для каждой подтверждённой записи с program_id > 0 вызывает
|
||||
* wallet::migrate3 (идемпотентно).
|
||||
*
|
||||
* Не-программные соглашения (program_id == 0) остаются в soviet::agreements3.
|
||||
*
|
||||
* Записи в soviet::agreements3 НЕ удаляются этой миграцией — это сделает
|
||||
* Эпик 4 (финальный deploy soviet); до тех пор мигрированные записи там
|
||||
* остаются как избыточная копия (контракт их игнорирует — sndagreement/
|
||||
* confirmagree/declineagree уже отказывают для program_id > 0).
|
||||
*/
|
||||
|
||||
import { SovietContract, WalletContract } from 'cooptypes';
|
||||
import type { Migration } from '../src/migration_interface';
|
||||
import { api } from '../src/eos';
|
||||
import { listCoops } from '../src/utils/listCoops';
|
||||
import { fetchAllRows } from '../src/utils/fetchAllRows';
|
||||
|
||||
const BATCH = 50; // максимум migrate3 actions в одной tx
|
||||
|
||||
export class MigrateAgreementsToWallet implements Migration {
|
||||
async run(): Promise<void> {
|
||||
const coops = await listCoops();
|
||||
console.log(`[047] Кооперативов найдено: ${coops.length}`);
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const coopname of coops) {
|
||||
const agreements = await fetchAllRows<SovietContract.Tables.Agreements.IAgreement>({
|
||||
code: SovietContract.contractName.production,
|
||||
scope: coopname,
|
||||
table: SovietContract.Tables.Agreements.tableName,
|
||||
});
|
||||
|
||||
const programmatic = agreements.filter((a) =>
|
||||
Number(a.program_id) > 0 && a.status === 'confirmed'
|
||||
);
|
||||
|
||||
if (programmatic.length === 0) {
|
||||
console.log(`[047] ${coopname}: нет программных соглашений — пропуск`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[047] ${coopname}: программных соглашений ${programmatic.length}`);
|
||||
|
||||
for (let i = 0; i < programmatic.length; i += BATCH) {
|
||||
const chunk = programmatic.slice(i, i + BATCH);
|
||||
const actions = chunk.map((a) => ({
|
||||
account: WalletContract.contractName.production,
|
||||
name: WalletContract.Actions.Migrate3.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data: {
|
||||
coopname,
|
||||
username: a.username,
|
||||
program_id: Number(a.program_id),
|
||||
doc_hash: a.document?.hash ?? '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
version: Number(a.version ?? 0),
|
||||
draft_id: Number(a.draft_id ?? 0),
|
||||
signed_at: a.updated_at,
|
||||
},
|
||||
}));
|
||||
|
||||
await api.transact(
|
||||
{ actions },
|
||||
{ blocksBehind: 3, expireSeconds: 30 }
|
||||
);
|
||||
|
||||
totalMigrated += chunk.length;
|
||||
console.log(`[047] ${coopname}: ${chunk.length} соглашений → wallet::users`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[047] Готово: всего мигрировано ${totalMigrated} соглашений`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Phase 1 ускоренный инкремент (Эпик 3 / story 3.6; ADR-008): миграция
|
||||
* минимальных паевых взносов w.reg.minshr per-coop из soviet::participants
|
||||
* в ledger2::userwallets через ledger2::migrate3.
|
||||
*
|
||||
* Логика:
|
||||
* Для каждого coop из registrator::coops:
|
||||
* 1. Читаем coop → coop.minimum (для individual/entrepreneur)
|
||||
* coop.org_minimum (для organization)
|
||||
* 2. Читаем soviet::participants[scope=coopname] со status=accepted
|
||||
* 3. Для каждого пайщика:
|
||||
* amount = coop.org_minimum (если type=organization) либо coop.minimum
|
||||
* ledger2::migrate3(coopname, w.reg.minshr, username, available=amount, blocked=0)
|
||||
*
|
||||
* Особенность ADR-008: w.reg.minshr — USER_SHARED, исключённый из cross-contract
|
||||
* проверки соглашений (сразу при регистрации, до подписания ЦК-соглашения).
|
||||
*
|
||||
* w.reg.entry (вступительные, COOPERATIVE) НЕ затрагивается этой миграцией —
|
||||
* он агрегатный (без L3-разбивки) и заполнен Эпиком 1 миграцией legacy::accounts.
|
||||
*
|
||||
* Идемпотентно: ledger2::migrate3 устанавливает значение (не +=).
|
||||
*/
|
||||
|
||||
import { Ledger2Contract, RegistratorContract, SovietContract } from 'cooptypes';
|
||||
import type { Migration } from '../src/migration_interface';
|
||||
import { api, rpc } from '../src/eos';
|
||||
import { listCoops } from '../src/utils/listCoops';
|
||||
import { fetchAllRows } from '../src/utils/fetchAllRows';
|
||||
|
||||
const W_REG_MINSHR = 'w.reg.minshr';
|
||||
const BATCH = 50;
|
||||
|
||||
interface ICoop {
|
||||
username: string;
|
||||
minimum: string;
|
||||
org_minimum?: string;
|
||||
}
|
||||
|
||||
async function getCoop(coopname: string): Promise<ICoop | null> {
|
||||
const rows = await rpc.get_table_rows({
|
||||
json: true,
|
||||
code: RegistratorContract.contractName.production,
|
||||
scope: RegistratorContract.contractName.production,
|
||||
table: RegistratorContract.Tables.Cooperatives.tableName,
|
||||
lower_bound: coopname,
|
||||
upper_bound: coopname,
|
||||
limit: 1,
|
||||
});
|
||||
return (rows.rows[0] as ICoop | undefined) ?? null;
|
||||
}
|
||||
|
||||
function pickMinimum(coop: ICoop, participantType: string | undefined): string {
|
||||
if (participantType === 'organization' && coop.org_minimum) {
|
||||
return coop.org_minimum;
|
||||
}
|
||||
return coop.minimum;
|
||||
}
|
||||
|
||||
function zeroOf(asset: string): string {
|
||||
// "100.0000 RUB" → "0.0000 RUB"
|
||||
const [amount, sym] = asset.split(' ');
|
||||
const decimals = (amount.split('.')[1] || '').length;
|
||||
return `${(0).toFixed(decimals)} ${sym}`;
|
||||
}
|
||||
|
||||
export class MigratePhase1Minshare implements Migration {
|
||||
async run(): Promise<void> {
|
||||
const coops = await listCoops();
|
||||
console.log(`[048] Кооперативов найдено: ${coops.length}`);
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const coopname of coops) {
|
||||
const coop = await getCoop(coopname);
|
||||
if (!coop) {
|
||||
console.warn(`[048] ${coopname}: не найден в registrator::coops — пропуск`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const participants = await fetchAllRows<SovietContract.Tables.Participants.IParticipants>({
|
||||
code: SovietContract.contractName.production,
|
||||
scope: coopname,
|
||||
table: SovietContract.Tables.Participants.tableName,
|
||||
});
|
||||
|
||||
const accepted = participants.filter((p) => p.status === 'accepted');
|
||||
if (accepted.length === 0) {
|
||||
console.log(`[048] ${coopname}: нет accepted-пайщиков — пропуск`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[048] ${coopname}: accepted-пайщиков ${accepted.length}`);
|
||||
const blockedZero = zeroOf(coop.minimum);
|
||||
|
||||
for (let i = 0; i < accepted.length; i += BATCH) {
|
||||
const chunk = accepted.slice(i, i + BATCH);
|
||||
const actions = chunk.map((p) => {
|
||||
const available = pickMinimum(coop, (p as any).type);
|
||||
return {
|
||||
account: Ledger2Contract.contractName.production,
|
||||
name: Ledger2Contract.Actions.Migrate3.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data: {
|
||||
coopname,
|
||||
wallet_name: W_REG_MINSHR,
|
||||
username: p.username,
|
||||
available,
|
||||
blocked: blockedZero,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await api.transact(
|
||||
{ actions },
|
||||
{ blocksBehind: 3, expireSeconds: 30 }
|
||||
);
|
||||
|
||||
totalMigrated += chunk.length;
|
||||
console.log(`[048] ${coopname}: ${chunk.length} пайщиков → ${W_REG_MINSHR}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[048] Готово: всего L3-записей w.reg.minshr создано ${totalMigrated}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Phase 2 (Эпик 3 / story 3.7): миграция soviet::progwallets → ledger2::userwallets
|
||||
* через ledger2::migrate3 (per-coop self-service).
|
||||
*
|
||||
* Маппинг program_id → wallet_name (см. эпик 3 §6 «Migrator Phase 2»):
|
||||
* 1 (ЦК) → split: w.wal.share + w.wal.member.
|
||||
* На текущем prod membership_contribution = 0, поэтому всё уходит
|
||||
* в w.wal.share, а w.wal.member не создаётся (no-op).
|
||||
* 3 (Генератор) → w.cap.gen
|
||||
* 4 (Благорост) → w.cap.blago
|
||||
* 2 (marketplace) — программных кошельков нет, пропускается.
|
||||
*
|
||||
* Особенности:
|
||||
* - Идемпотентно: ledger2::migrate3 устанавливает значение (не +=).
|
||||
* - Без удаления записей из soviet::progwallets — это сделает Эпик 4
|
||||
* (финальный deploy soviet); до тех пор записи остаются как избыточная
|
||||
* копия (новые operations пишут только в ledger2::userwallets).
|
||||
*
|
||||
* Pre-flight gate (NFR18) выполняется ВНЕШНИМ скриптом на staging:
|
||||
* сравнение Σ progwallets (pre) vs Σ userwallets (post) per (coopname, username,
|
||||
* program_id) — дельта 0 копеек до production rollout.
|
||||
*/
|
||||
|
||||
import { Ledger2Contract, SovietContract } from 'cooptypes';
|
||||
import type { Migration } from '../src/migration_interface';
|
||||
import { api } from '../src/eos';
|
||||
import { listCoops } from '../src/utils/listCoops';
|
||||
import { fetchAllRows } from '../src/utils/fetchAllRows';
|
||||
|
||||
const PROGRAM_TO_WALLET: Record<number, string> = {
|
||||
1: 'w.wal.share',
|
||||
3: 'w.cap.gen',
|
||||
4: 'w.cap.blago',
|
||||
};
|
||||
|
||||
const BATCH = 50;
|
||||
|
||||
export class MigratePhase2Progwallets implements Migration {
|
||||
async run(): Promise<void> {
|
||||
const coops = await listCoops();
|
||||
console.log(`[049] Кооперативов найдено: ${coops.length}`);
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const coopname of coops) {
|
||||
const progwallets = await fetchAllRows<SovietContract.Tables.ProgramWallets.IProgramWallet>({
|
||||
code: SovietContract.contractName.production,
|
||||
scope: coopname,
|
||||
table: SovietContract.Tables.ProgramWallets.tableName,
|
||||
});
|
||||
|
||||
const eligible = progwallets.filter((pw) => PROGRAM_TO_WALLET[Number(pw.program_id)]);
|
||||
if (eligible.length === 0) {
|
||||
console.log(`[049] ${coopname}: нет progwallets под маппинг — пропуск`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[049] ${coopname}: progwallets ${eligible.length}`);
|
||||
|
||||
for (let i = 0; i < eligible.length; i += BATCH) {
|
||||
const chunk = eligible.slice(i, i + BATCH);
|
||||
const actions = chunk.map((pw) => ({
|
||||
account: Ledger2Contract.contractName.production,
|
||||
name: Ledger2Contract.Actions.Migrate3.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data: {
|
||||
coopname,
|
||||
wallet_name: PROGRAM_TO_WALLET[Number(pw.program_id)]!,
|
||||
username: pw.username,
|
||||
available: pw.available,
|
||||
blocked: pw.blocked,
|
||||
},
|
||||
}));
|
||||
|
||||
await api.transact(
|
||||
{ actions },
|
||||
{ blocksBehind: 3, expireSeconds: 30 }
|
||||
);
|
||||
|
||||
totalMigrated += chunk.length;
|
||||
console.log(`[049] ${coopname}: ${chunk.length} progwallets → userwallets`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[049] Готово: всего L3-записей migrate3 ${totalMigrated}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { rpc } from '../eos';
|
||||
|
||||
/**
|
||||
* Полный обход таблицы блокчейна с пагинацией.
|
||||
*
|
||||
* Используется миграциями для чтения soviet::agreements3, soviet::progwallets,
|
||||
* soviet::participants и аналогичных таблиц с произвольным числом записей.
|
||||
*/
|
||||
export async function fetchAllRows<T = any>(opts: {
|
||||
code: string;
|
||||
scope: string;
|
||||
table: string;
|
||||
index_position?: number;
|
||||
key_type?: string;
|
||||
limit?: number;
|
||||
}): Promise<T[]> {
|
||||
const result: T[] = [];
|
||||
let lower_bound: string | undefined;
|
||||
const limit = opts.limit ?? 200;
|
||||
|
||||
while (true) {
|
||||
const rows = await rpc.get_table_rows({
|
||||
json: true,
|
||||
code: opts.code,
|
||||
scope: opts.scope,
|
||||
table: opts.table,
|
||||
index_position: opts.index_position,
|
||||
key_type: opts.key_type,
|
||||
limit,
|
||||
lower_bound,
|
||||
});
|
||||
|
||||
for (const row of rows.rows as T[]) {
|
||||
result.push(row);
|
||||
}
|
||||
|
||||
if (!rows.more) break;
|
||||
lower_bound = rows.next_key;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { rpc } from '../eos';
|
||||
import { RegistratorContract } from 'cooptypes';
|
||||
|
||||
/**
|
||||
* Возвращает список coopname-ов всех активных кооперативов из
|
||||
* `registrator::cooperatives2`. Используется как драйвер per-coop миграций.
|
||||
*/
|
||||
export async function listCoops(): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
let lower_bound: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const rows = await rpc.get_table_rows({
|
||||
json: true,
|
||||
code: RegistratorContract.contractName.production,
|
||||
scope: RegistratorContract.contractName.production,
|
||||
table: RegistratorContract.Tables.Cooperatives.tableName,
|
||||
limit: 100,
|
||||
lower_bound,
|
||||
});
|
||||
|
||||
for (const row of rows.rows as Array<{ username: string; is_cooperative: boolean; status?: string }>) {
|
||||
if (row.is_cooperative && (row.status === undefined || row.status === 'active')) {
|
||||
result.push(row.username);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.more) break;
|
||||
lower_bound = rows.next_key;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user