Q136: remove rollback records that marked restores nothing performed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8f852c326
commit
6e210301db
20 files changed
+31
-757
No files matched your search
@@ -52,9 +52,6 @@ export const platformContracts = {
|
||||
openFile: z.strictObject({ orgId: id, fileId: id }) satisfies z.ZodType<C.OpenFileInput>,
|
||||
setFilePinned: z.strictObject({ orgId: id, fileId: id, pinned: z.boolean() }) satisfies z.ZodType<C.SetFilePinnedInput>,
|
||||
createMigrationReport: z.strictObject({ orgId: id, sourceDatabasePath: z.string() }) satisfies z.ZodType<C.CreateMigrationReportInput>,
|
||||
createRollbackSnapshot: z.strictObject({ orgId: id, migrationReportId: id, createdByEmployeeId: id }) satisfies z.ZodType<C.CreateRollbackSnapshotInput>,
|
||||
setRollbackSnapshotActive: z.strictObject({ orgId: id, snapshotId: id, active: z.boolean(), employeeId: id }) satisfies z.ZodType<C.SetRollbackSnapshotActiveInput>,
|
||||
resolveMigrationReconciliation: z.strictObject({ orgId: id, reconciliationItemId: id, employeeId: id }) satisfies z.ZodType<C.ResolveMigrationReconciliationInput>,
|
||||
setFeatureFlag: z.strictObject({ orgId: id, flagId: id, enabled: z.boolean() }) satisfies z.ZodType<C.SetFeatureFlagInput>,
|
||||
createComplianceExport: z.strictObject({
|
||||
orgId: id, subjectType: z.enum(["employee", "agent"]), subjectId: id, exportType: z.enum(COMPLIANCE_EXPORT_TYPE), requestedByEmployeeId: id,
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ComplianceService } from "../services/compliance-service";
|
||||
import { SearchService } from "../services/search-service";
|
||||
import { IdentityAccessService } from "../services/identity-access-service";
|
||||
import { OperationsService } from "../services/operations-service";
|
||||
import { RollbackService } from "../services/rollback-service";
|
||||
import { TaskExecutionService } from "../services/task-execution-service";
|
||||
import { DocumentService } from "../services/document-service";
|
||||
import { KnowledgeService } from "../services/knowledge-service";
|
||||
@@ -76,7 +75,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const search = new SearchService(deps.connection.db);
|
||||
const identityAccess = new IdentityAccessService(deps.connection.db);
|
||||
const operations = new OperationsService(deps.connection.db);
|
||||
const rollback = new RollbackService(deps.connection.db);
|
||||
const documents = new DocumentService(deps.connection.db);
|
||||
const knowledge = new KnowledgeService(deps.connection.db);
|
||||
const automation = new AutomationService(deps.connection.db);
|
||||
@@ -370,9 +368,6 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
});
|
||||
|
||||
handle("createMigrationReport", (input) => mutate(() => migrations.createReport(input)));
|
||||
handle("createRollbackSnapshot", (input) => mutate(() => rollback.createSnapshot(input)));
|
||||
handle("setRollbackSnapshotActive", (input) => mutate(() => rollback.setActive(input)));
|
||||
handle("resolveMigrationReconciliation", (input) => mutate(() => rollback.resolveReconciliationItem(input)));
|
||||
handle("setFeatureFlag", (input) => mutate(() => operations.setFeatureFlag(input)));
|
||||
handle("createComplianceExport", (input) => mutate(() => compliance.createExport(input)));
|
||||
handle("createLegalHold", (input) => mutate(() => compliance.createLegalHold(input)));
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Rollback snapshots, cutover events and reconciliation items only recorded states; nothing ever restored a database, file or outside action, so reports drop their restore promise too.
|
||||
export const dropRollbackRecordsSchema = `
|
||||
drop table if exists migration_reconciliation_items;
|
||||
drop table if exists migration_cutover_events;
|
||||
drop table if exists rollback_snapshots;
|
||||
update migration_reports set report_json = json_remove(report_json, '$.rollbackPack');
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (99, 'drop_rollback_records', datetime('now'));
|
||||
`;
|
||||
@@ -97,6 +97,7 @@ import { dropPrBotGatesSchema } from "./migrations/095-drop-pr-bot-gates";
|
||||
import { dropChaosDrillsSchema } from "./migrations/096-drop-chaos-drills";
|
||||
import { dropDrillOnlySloSchema } from "./migrations/097-drop-drill-only-slo";
|
||||
import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays";
|
||||
import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -203,6 +204,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 96, name: "drop_chaos_drills", sql: dropChaosDrillsSchema },
|
||||
{ version: 97, name: "drop_drill_only_slo", sql: dropDrillOnlySloSchema },
|
||||
{ version: 98, name: "drop_audit_replays", sql: dropAuditReplaysSchema },
|
||||
{ version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -200,10 +200,6 @@ export class ComplianceService {
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update retention_freezes set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update rollback_snapshots set created_by_employee_id = null where org_id = ? and created_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("update migration_reconciliation_items set resolved_by_employee_id = null where org_id = ? and resolved_by_employee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("delete from approval_assignments where org_id = ? and assignee_type = 'employee' and assignee_id = ?")
|
||||
.run(orgId, subjectId);
|
||||
this.db.prepare("delete from role_grants where org_id = ? and principal_type = 'employee' and principal_id = ?")
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
const REPLAYABLE_ENTITY_TYPES = new Set([
|
||||
"agent",
|
||||
"approval",
|
||||
"browser_action",
|
||||
"chat_message",
|
||||
"chat_space",
|
||||
"file",
|
||||
"scheduled_post",
|
||||
"social_account",
|
||||
"task",
|
||||
"workspace",
|
||||
]);
|
||||
|
||||
export interface CutoverDeltaEvent {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecordCutoverEventInput {
|
||||
orgId: string;
|
||||
migrationReportId: string;
|
||||
rollbackSnapshotId: string | null;
|
||||
eventType: string;
|
||||
actorType: string;
|
||||
actorId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export class MigrationCutoverJournalService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
recordEvent(input: RecordCutoverEventInput): string {
|
||||
const id = createId("mce");
|
||||
this.db.prepare(`
|
||||
insert into migration_cutover_events(
|
||||
id, org_id, migration_report_id, rollback_snapshot_id, event_type,
|
||||
actor_type, actor_id, payload_json, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
input.migrationReportId,
|
||||
input.rollbackSnapshotId,
|
||||
input.eventType,
|
||||
input.actorType,
|
||||
input.actorId,
|
||||
JSON.stringify(input.payload),
|
||||
input.createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
queueReconciliation(input: {
|
||||
orgId: string;
|
||||
migrationReportId: string;
|
||||
rollbackSnapshotId: string;
|
||||
events: CutoverDeltaEvent[];
|
||||
createdAt?: string;
|
||||
}): number {
|
||||
let queued = 0;
|
||||
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||
const statement = this.db.prepare(`
|
||||
insert or ignore into migration_reconciliation_items(
|
||||
id, org_id, migration_report_id, rollback_snapshot_id, source_event_id,
|
||||
target_type, target_id, reason, state, payload_json, created_at, resolved_at, resolved_by_employee_id
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, null, null)
|
||||
`);
|
||||
for (const event of input.events) {
|
||||
if (!REPLAYABLE_ENTITY_TYPES.has(event.entityType)) continue;
|
||||
const result = statement.run(
|
||||
createId("mri"),
|
||||
input.orgId,
|
||||
input.migrationReportId,
|
||||
input.rollbackSnapshotId,
|
||||
event.id,
|
||||
event.entityType,
|
||||
event.entityId,
|
||||
"manual_delta_replay",
|
||||
"open",
|
||||
JSON.stringify({
|
||||
action: event.action,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
payload: event.payload,
|
||||
createdAt: event.createdAt,
|
||||
}),
|
||||
createdAt,
|
||||
);
|
||||
queued += Number(result.changes);
|
||||
}
|
||||
return queued;
|
||||
}
|
||||
}
|
||||
@@ -70,9 +70,6 @@ const LEGACY_TABLES = [
|
||||
"legal_holds",
|
||||
"retention_freezes",
|
||||
"search_documents",
|
||||
"rollback_snapshots",
|
||||
"migration_cutover_events",
|
||||
"migration_reconciliation_items",
|
||||
"audit_events",
|
||||
];
|
||||
|
||||
@@ -98,14 +95,6 @@ interface MigrationReportBody {
|
||||
missingTables: string[];
|
||||
failures: MigrationFailure[];
|
||||
validationSummary: MigrationValidationSummary;
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: string;
|
||||
capturedAt: string;
|
||||
restoreStrategy: string;
|
||||
sourceFingerprint: string;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
const RELATION_CHECKS: RelationCheck[] = [
|
||||
@@ -238,16 +227,6 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "legal_holds", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "retention_freezes", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "retention_freezes", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "rollback_snapshots", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "rollback_snapshots", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "rollback_snapshots", column: "created_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_creator_employee_id" },
|
||||
{ table: "migration_cutover_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "migration_cutover_events", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "migration_cutover_events", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
|
||||
{ table: "migration_reconciliation_items", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "migration_reconciliation_items", column: "migration_report_id", referencedTable: "migration_reports", referencedColumn: "id", reason: "dangling_migration_report_id" },
|
||||
{ table: "migration_reconciliation_items", column: "rollback_snapshot_id", referencedTable: "rollback_snapshots", referencedColumn: "id", reason: "dangling_rollback_snapshot_id" },
|
||||
{ table: "migration_reconciliation_items", column: "resolved_by_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_resolver_employee_id" },
|
||||
{ table: "audit_events", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
];
|
||||
|
||||
@@ -314,14 +293,6 @@ export class MigrationService {
|
||||
missingTables,
|
||||
failures,
|
||||
validationSummary,
|
||||
rollbackPack: {
|
||||
sourceDatabasePath,
|
||||
capturedAt: now,
|
||||
restoreStrategy: "legacy_snapshot_restore",
|
||||
sourceFingerprint,
|
||||
tableChecksums,
|
||||
idMaps,
|
||||
},
|
||||
};
|
||||
this.db.prepare(`
|
||||
insert into migration_reports(id, org_id, source_ref, report_json, created_at)
|
||||
@@ -446,7 +417,7 @@ function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "rollbackPack" | "validationSummary">): string {
|
||||
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "validationSummary">): string {
|
||||
return createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, existsSync, openSync, readSync, realpathSync } from "node:fs";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateRollbackSnapshotInput, ResolveMigrationReconciliationInput, SetRollbackSnapshotActiveInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { MigrationCutoverJournalService, type CutoverDeltaEvent } from "./migration-cutover-journal-service";
|
||||
|
||||
interface MigrationReportRow {
|
||||
id: string;
|
||||
source_ref: string;
|
||||
report_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface MigrationReportBody {
|
||||
sourceFingerprint: string;
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: string;
|
||||
restoreStrategy: string;
|
||||
sourceFingerprint: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
local_path: string;
|
||||
repo_url: string;
|
||||
repo_ref: string;
|
||||
is_primary: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface RollbackSnapshotRow {
|
||||
id: string;
|
||||
migration_report_id: string;
|
||||
source_fingerprint: string;
|
||||
delta_journal_json: string;
|
||||
}
|
||||
|
||||
interface MigrationReconciliationItemRow {
|
||||
id: string;
|
||||
migration_report_id: string;
|
||||
rollback_snapshot_id: string | null;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface FileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
storage_path: string;
|
||||
scan_state: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface AuditRow {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
action: string;
|
||||
payload_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class RollbackService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly cutoverJournal: MigrationCutoverJournalService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
this.cutoverJournal = new MigrationCutoverJournalService(db);
|
||||
}
|
||||
|
||||
createSnapshot(input: CreateRollbackSnapshotInput): string {
|
||||
this.requireEmployee(input.orgId, input.createdByEmployeeId);
|
||||
const report = this.requireReport(input.orgId, input.migrationReportId);
|
||||
const body = parseMigrationReport(report.report_json);
|
||||
const sourceDatabasePath = realSourcePath(body.rollbackPack.sourceDatabasePath);
|
||||
if (sourceDatabasePath !== realSourcePath(report.source_ref)) throw new Error("Rollback source path does not match the migration report.");
|
||||
if (body.sourceFingerprint !== body.rollbackPack.sourceFingerprint) throw new Error("Rollback pack fingerprint is invalid.");
|
||||
const sourceDatabaseSha256 = fileSha256(sourceDatabasePath);
|
||||
const workspaceManifestJson = JSON.stringify(this.workspaceManifest(input.orgId));
|
||||
const workspaceManifestSha256 = sha256(workspaceManifestJson);
|
||||
const deltaJournalJson = JSON.stringify(this.deltaJournal(input.orgId, report.created_at));
|
||||
const restorePlanJson = JSON.stringify({
|
||||
migrationReportId: report.id,
|
||||
sourceDatabasePath,
|
||||
sourceFingerprint: body.sourceFingerprint,
|
||||
sourceDatabaseSha256,
|
||||
workspaceManifestSha256,
|
||||
restoreStrategy: body.rollbackPack.restoreStrategy,
|
||||
});
|
||||
const id = createId("rbs");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into rollback_snapshots(
|
||||
id, org_id, migration_report_id, source_database_path, source_fingerprint,
|
||||
source_database_sha256, workspace_manifest_json, workspace_manifest_sha256,
|
||||
delta_journal_json, restore_plan_json, active, created_by_employee_id, created_at, activated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, null)
|
||||
`).run(
|
||||
id,
|
||||
input.orgId,
|
||||
report.id,
|
||||
sourceDatabasePath,
|
||||
body.sourceFingerprint,
|
||||
sourceDatabaseSha256,
|
||||
workspaceManifestJson,
|
||||
workspaceManifestSha256,
|
||||
deltaJournalJson,
|
||||
restorePlanJson,
|
||||
input.createdByEmployeeId,
|
||||
now,
|
||||
);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "rollback_snapshot",
|
||||
entityId: id,
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
action: "created",
|
||||
payload: { migrationReportId: report.id, sourceFingerprint: body.sourceFingerprint },
|
||||
});
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: report.id,
|
||||
rollbackSnapshotId: id,
|
||||
eventType: "rollback_snapshot_created",
|
||||
actorType: "employee",
|
||||
actorId: input.createdByEmployeeId,
|
||||
payload: { sourceFingerprint: body.sourceFingerprint, sourceDatabaseSha256, workspaceManifestSha256 },
|
||||
createdAt: now,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
setActive(input: SetRollbackSnapshotActiveInput): void {
|
||||
this.requireEmployee(input.orgId, input.employeeId);
|
||||
const snapshot = this.db.prepare(`
|
||||
select id, migration_report_id, source_fingerprint, delta_journal_json
|
||||
from rollback_snapshots
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.snapshotId, input.orgId) as RollbackSnapshotRow | undefined;
|
||||
if (!snapshot) throw new Error("Rollback snapshot was not found.");
|
||||
const now = new Date().toISOString();
|
||||
if (input.active) this.db.prepare("update rollback_snapshots set active = 0, activated_at = null where org_id = ?").run(input.orgId);
|
||||
const result = this.db.prepare("update rollback_snapshots set active = ?, activated_at = ? where id = ? and org_id = ?")
|
||||
.run(input.active ? 1 : 0, input.active ? now : null, input.snapshotId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Rollback snapshot was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "rollback_snapshot",
|
||||
entityId: input.snapshotId,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: input.active ? "activated" : "deactivated",
|
||||
payload: { active: input.active },
|
||||
});
|
||||
const queuedReconciliationItems = input.active
|
||||
? this.cutoverJournal.queueReconciliation({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: snapshot.migration_report_id,
|
||||
rollbackSnapshotId: snapshot.id,
|
||||
events: parseDeltaJournal(snapshot.delta_journal_json),
|
||||
createdAt: now,
|
||||
})
|
||||
: 0;
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: snapshot.migration_report_id,
|
||||
rollbackSnapshotId: snapshot.id,
|
||||
eventType: input.active ? "rollback_snapshot_activated" : "rollback_snapshot_deactivated",
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
payload: { active: input.active, sourceFingerprint: snapshot.source_fingerprint, queuedReconciliationItems },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
resolveReconciliationItem(input: ResolveMigrationReconciliationInput): void {
|
||||
this.requireEmployee(input.orgId, input.employeeId);
|
||||
const item = this.db.prepare(`
|
||||
select id, migration_report_id, rollback_snapshot_id, target_type, target_id, state
|
||||
from migration_reconciliation_items
|
||||
where id = ? and org_id = ?
|
||||
`).get(input.reconciliationItemId, input.orgId) as MigrationReconciliationItemRow | undefined;
|
||||
if (!item) throw new Error("Migration reconciliation item was not found.");
|
||||
if (item.state === "resolved") throw new Error("Migration reconciliation item is already resolved.");
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.prepare(`
|
||||
update migration_reconciliation_items
|
||||
set state = ?, resolved_at = ?, resolved_by_employee_id = ?
|
||||
where id = ? and org_id = ? and state = ?
|
||||
`).run("resolved", now, input.employeeId, item.id, input.orgId, "open");
|
||||
if (result.changes === 0) throw new Error("Migration reconciliation item could not be resolved.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "migration_reconciliation_item",
|
||||
entityId: item.id,
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
action: "resolved",
|
||||
payload: { targetType: item.target_type, targetId: item.target_id, rollbackSnapshotId: item.rollback_snapshot_id },
|
||||
});
|
||||
this.cutoverJournal.recordEvent({
|
||||
orgId: input.orgId,
|
||||
migrationReportId: item.migration_report_id,
|
||||
rollbackSnapshotId: item.rollback_snapshot_id,
|
||||
eventType: "migration_reconciliation_resolved",
|
||||
actorType: "employee",
|
||||
actorId: input.employeeId,
|
||||
payload: { reconciliationItemId: item.id, targetType: item.target_type, targetId: item.target_id },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
private requireReport(orgId: string, reportId: string): MigrationReportRow {
|
||||
const report = this.db.prepare("select id, source_ref, report_json, created_at from migration_reports where id = ? and org_id = ?")
|
||||
.get(reportId, orgId) as MigrationReportRow | undefined;
|
||||
if (!report) throw new Error("Migration report was not found.");
|
||||
return report;
|
||||
}
|
||||
|
||||
private requireEmployee(orgId: string, employeeId: string): void {
|
||||
const employee = this.db.prepare("select id from employees where id = ? and org_id = ?").get(employeeId, orgId);
|
||||
if (!employee) throw new Error("Rollback employee was not found.");
|
||||
}
|
||||
|
||||
private workspaceManifest(orgId: string): { workspaces: unknown[]; files: unknown[] } {
|
||||
const workspaces = this.db.prepare(`
|
||||
select id, name, local_path, repo_url, repo_ref, is_primary, updated_at
|
||||
from workspaces
|
||||
where org_id = ?
|
||||
order by id asc
|
||||
`).all(orgId) as unknown as WorkspaceRow[];
|
||||
const files = this.db.prepare(`
|
||||
select id, name, size_bytes, sha256, storage_path, scan_state, updated_at
|
||||
from file_objects
|
||||
where org_id = ?
|
||||
order by id asc
|
||||
`).all(orgId) as unknown as FileRow[];
|
||||
return {
|
||||
workspaces: workspaces.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
localPath: row.local_path,
|
||||
repoUrl: row.repo_url,
|
||||
repoRef: row.repo_ref,
|
||||
isPrimary: row.is_primary === 1,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
files: files.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sizeBytes: row.size_bytes,
|
||||
sha256: row.sha256,
|
||||
storagePath: row.storage_path,
|
||||
scanState: row.scan_state,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private deltaJournal(orgId: string, since: string): unknown[] {
|
||||
const rows = this.db.prepare(`
|
||||
select id, entity_type, entity_id, actor_type, actor_id, action, payload_json, created_at
|
||||
from audit_events
|
||||
where org_id = ? and created_at >= ?
|
||||
order by created_at asc, id asc
|
||||
`).all(orgId, since) as unknown as AuditRow[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
entityType: row.entity_type,
|
||||
entityId: row.entity_id,
|
||||
actorType: row.actor_type,
|
||||
actorId: row.actor_id,
|
||||
action: row.action,
|
||||
payload: parseRecord(row.payload_json),
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function parseMigrationReport(reportJson: string): MigrationReportBody {
|
||||
const parsed = parseRecord(reportJson);
|
||||
const rollbackPack = parsed.rollbackPack;
|
||||
if (typeof parsed.sourceFingerprint !== "string") throw new Error("Migration report is missing a source fingerprint.");
|
||||
if (typeof rollbackPack !== "object" || rollbackPack === null || Array.isArray(rollbackPack)) throw new Error("Migration report is missing a rollback pack.");
|
||||
const pack = rollbackPack as Record<string, unknown>;
|
||||
if (typeof pack.sourceDatabasePath !== "string" || typeof pack.restoreStrategy !== "string" || typeof pack.sourceFingerprint !== "string") {
|
||||
throw new Error("Migration rollback pack is invalid.");
|
||||
}
|
||||
return {
|
||||
sourceFingerprint: parsed.sourceFingerprint,
|
||||
rollbackPack: {
|
||||
sourceDatabasePath: pack.sourceDatabasePath,
|
||||
restoreStrategy: pack.restoreStrategy,
|
||||
sourceFingerprint: pack.sourceFingerprint,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function realSourcePath(path: string): string {
|
||||
if (!existsSync(path)) throw new Error("Rollback source database was not found.");
|
||||
return realpathSync.native(path);
|
||||
}
|
||||
|
||||
function fileSha256(path: string): string {
|
||||
const fd = openSync(path, "r");
|
||||
try {
|
||||
const hash = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
for (;;) {
|
||||
const bytes = readSync(fd, buffer, 0, buffer.length, null);
|
||||
if (bytes === 0) return hash.digest("hex");
|
||||
hash.update(buffer.subarray(0, bytes));
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function parseRecord(json: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JSON record is invalid.");
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseDeltaJournal(json: string): CutoverDeltaEvent[] {
|
||||
const parsed = JSON.parse(json) as unknown;
|
||||
if (!Array.isArray(parsed)) throw new Error("Rollback delta journal is invalid.");
|
||||
return parsed.map((item) => {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) throw new Error("Rollback delta journal entry is invalid.");
|
||||
const record = item as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string"
|
||||
|| typeof record.entityType !== "string"
|
||||
|| typeof record.entityId !== "string"
|
||||
|| typeof record.actorType !== "string"
|
||||
|| typeof record.action !== "string"
|
||||
|| typeof record.createdAt !== "string"
|
||||
) {
|
||||
throw new Error("Rollback delta journal entry is invalid.");
|
||||
}
|
||||
const payload = record.payload;
|
||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("Rollback delta journal entry is invalid.");
|
||||
return {
|
||||
id: record.id,
|
||||
entityType: record.entityType,
|
||||
entityId: record.entityId,
|
||||
actorType: record.actorType,
|
||||
actorId: typeof record.actorId === "string" ? record.actorId : null,
|
||||
action: record.action,
|
||||
payload: payload as Record<string, unknown>,
|
||||
createdAt: record.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -79,8 +79,6 @@ export class SnapshotService {
|
||||
.map((flag) => ({ ...flag, enabled: toBool(flag.enabled) }));
|
||||
const operationalEvents = list<AppSnapshot["operationalEvents"][number]>(this.db, "operational_events", "created_at desc")
|
||||
.map((event) => ({ ...event, success: toBool(event.success) }));
|
||||
const rollbackSnapshots = list<AppSnapshot["rollbackSnapshots"][number]>(this.db, "rollback_snapshots", "created_at desc")
|
||||
.map((snapshot) => ({ ...snapshot, active: toBool(snapshot.active) }));
|
||||
|
||||
return {
|
||||
activeOrgId,
|
||||
@@ -139,9 +137,6 @@ export class SnapshotService {
|
||||
socialAuditRecords: list(this.db, "social_audit_records", "created_at desc"),
|
||||
policyRules: list(this.db, "policy_rules", "created_at asc"),
|
||||
migrationReports: list(this.db, "migration_reports", "created_at desc"),
|
||||
rollbackSnapshots,
|
||||
migrationCutoverEvents: list(this.db, "migration_cutover_events", "created_at desc"),
|
||||
migrationReconciliationItems: list(this.db, "migration_reconciliation_items", "created_at desc"),
|
||||
complianceExports: list(this.db, "compliance_exports", "created_at desc"),
|
||||
legalHolds,
|
||||
retentionFreezes,
|
||||
|
||||
Reference in new issue
Block a user