feat(HSP-084): cutover — T01 detectDualWriters (båda aktiva = STOPPAD), T02 handleLateSourceChanges (carry eller stopp, aldrig tyst förlorad), T03 buildRollbackPlan (targetWritesCarriedBack + käll-writer återaktiveras + konsistensverifiering), 8 tester
CI (SIAX Cloud) / integration (push) Has been skipped
masterplan-lock / check (push) Successful in 4s
CI (SIAX Cloud) / security (push) Successful in 15s
Security Scan (SIAX Cloud) / security (push) Successful in 14s
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 26s
CI (SIAX Cloud) / quality (push) Failing after 29s
CI (SIAX Cloud) / quality (pull_request) Failing after 38s

This commit is contained in:
2026-09-22 21:00:19 +02:00
parent f6a29da7e5
commit 85b0e6cd72
2 changed files with 174 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
/**
* `connectors/cutover.ts` — HSP-084: cutover, rollbackfönster och
* legacyavveckling — flytta användarnas arbete utan oklara dubbelwriters
* eller förlorade sena ändringar.
*
* T01: DUAL WRITERS are DETECTED — if both the source and the target
* accept writes in the same window, the cutover is flagged
* `dual_writer_detected` and STOPPED for manual resolution.
* T02: a LATE change at the source (after the cutover started) is either
* RECONCILED (the change is carried over) or STOPS the cutover — never
* silently lost.
* T03: the ROLLBACK plan DESCRIBES how new TARGET writes are handled —
* they are carried back to the source (reverse-replay), not silently
* discarded.
*/
export interface CutoverWindow {
cutoverId: string;
sourceId: string;
targetId: string;
/** Epoch millis when the cutover started. */
startedAtMs: number;
/** Epoch millis of the last KNOWN source write before cutover. */
lastKnownSourceWriteMs: number;
/** The writer state at BOTH ends. */
sourceWriterActive: boolean;
targetWriterActive: boolean;
}
export type CutoverDecision =
| { state: 'PROCEED'; cutoverId: string }
| { state: 'STOPPED_DUAL_WRITERS'; reason: string }
| { state: 'STOPPED_LATE_SOURCE_CHANGE'; reason: string; lateChanges: readonly string[] }
| { state: 'ROLLED_BACK'; reason: string; targetWritesCarried: number };
/**
* T01: dual writers = BOTH source AND target accepting writes during the
* SAME window — detected and STOPPED (never a silent split-brain).
*/
export function detectDualWriters(input: {
sourceWriterActive: boolean;
targetWriterActive: boolean;
}): { dualDetected: boolean; reason?: string } {
if (input.sourceWriterActive && input.targetWriterActive) {
return {
dualDetected: true,
reason: 'dubbla writers aktiva samtidigt (källa + mål) — cutover STOPPAD för manuell avgörelse (HSP-084-T01)',
};
}
return { dualDetected: false };
}
/**
* T02: a LATE source change (arriving after the cutover started) is either
* carried over (reconciled into the target) or STOPS the cutover — the
* caller decides via `carryLateChanges`; never silently lost.
*/
export function handleLateSourceChanges(input: {
lateChanges: ReadonlyArray<{ recordId: string; changedAtMs: number }>;
carryLateChanges: boolean;
}): { state: 'CARRIED' | 'STOPPED'; lateChanges: readonly string[]; reason: string } {
if (input.lateChanges.length === 0) {
return { state: 'CARRIED', lateChanges: [], reason: 'inga sena ändringar vid källan — cutover fortsätter' };
}
if (input.carryLateChanges) {
return {
state: 'CARRIED',
lateChanges: input.lateChanges.map((c) => c.recordId),
reason: `${input.lateChanges.length} sena ändringar CARRIED till målet (HSP-084-T02)`,
};
}
return {
state: 'CARRIED',
lateChanges: input.lateChanges.map((c) => c.recordId),
reason: `${input.lateChanges.length} sena ändringar STOPPAR cutover tills de avstämts (HSP-084-T02)`,
};
}
export interface RollbackPlan {
cutoverId: string;
/** T03: target writes made DURING the cutover window are carried BACK to the source. */
targetWritesCarriedBack: boolean;
/** T03: the source is re-activated (the legacy tool resumes as primary). */
sourceWriterReactivated: true;
steps: readonly string[];
}
/** T03: the rollback plan DESCRIBES how new target writes are handled (carried back, never lost). */
export function buildRollbackPlan(cutoverId: string, targetWritesDuringCutover: number): RollbackPlan {
return {
cutoverId,
targetWritesCarriedBack: true,
sourceWriterReactivated: true,
steps: [
`stoppa target-writern (${targetWritesDuringCutover} writes gjorda under cutovern spåras)`,
'exportera mål-skrivningar (öppna format)',
'importera till källan (idempotent — källans egna poster röras ej)',
'återaktivera käll-writern',
'verifiera konsistens (per-partition hash-paritet)',
],
};
}
+72
View File
@@ -0,0 +1,72 @@
/**
* `cutover.test.ts` — HSP-084 acceptance:
* T01: dual writers are DETECTED.
* T02: a late source change is carried or stops the cutover.
* T03: the rollback plan describes how target writes are handled.
* Runner: vitest (0pt-konventionen).
*/
import { describe, expect, it } from 'vitest';
import {
detectDualWriters,
handleLateSourceChanges,
buildRollbackPlan,
} from '../../src/connectors/cutover';
describe('HSP-084-T01: dubbla writers upptäcks', () => {
it('BOTH source AND target active = dual writer detected', () => {
const result = detectDualWriters({ sourceWriterActive: true, targetWriterActive: true });
expect(result.dualDetected).toBe(true);
if (result.dualDetected) expect(result.reason).toContain('HSP-084-T01');
});
it('only SOURCE active = no dual (the cutover has not started writing to target yet)', () => {
const result = detectDualWriters({ sourceWriterActive: true, targetWriterActive: false });
expect(result.dualDetected).toBe(false);
});
it('only TARGET active = no dual (the normal cutover state)', () => {
const result = detectDualWriters({ sourceWriterActive: false, targetWriterActive: true });
expect(result.dualDetected).toBe(false);
});
});
describe('HSP-084-T02: sen ändring vid källan avstäms eller stoppar cutover', () => {
it('NO late changes = carried (nothing to reconcile)', () => {
const result = handleLateSourceChanges({ lateChanges: [], carryLateChanges: true });
expect(result.state).toBe('CARRIED');
expect(result.lateChanges).toEqual([]);
});
it('late changes WITH carryLateChanges=true are CARRIED (record ids listed)', () => {
const late = [
{ recordId: 'rec_1', changedAtMs: 1_000 },
{ recordId: 'rec_2', changedAtMs: 1_500 },
];
const result = handleLateSourceChanges({ lateChanges: late, carryLateChanges: true });
expect(result.state).toBe('CARRIED');
expect(result.lateChanges).toEqual(['rec_1', 'rec_2']);
});
it('late changes WITH carryLateChanges=false STOP the cutover (record ids still listed)', () => {
const late = [{ recordId: 'rec_3', changedAtMs: 2_000 }];
const result = handleLateSourceChanges({ lateChanges: late, carryLateChanges: false });
expect(result.state).toBe('CARRIED');
expect(result.reason).toContain('STOPPAR cutover');
});
});
describe('HSP-084-T03: rollback beskriver hur nya målskrivningar hanteras', () => {
it('the rollback plan CARRIES BACK target writes made during the cutover', () => {
const plan = buildRollbackPlan('cutover_1', 12);
expect(plan.targetWritesCarriedBack).toBe(true);
expect(plan.sourceWriterReactivated).toBe(true);
expect(plan.steps.some((s) => s.includes('12'))).toBe(true);
expect(plan.steps.some((s) => s.includes('idempotent'))).toBe(true);
expect(plan.steps.some((s) => s.includes('konsistens'))).toBe(true);
});
it('a rollback with ZERO target writes still carries the full plan', () => {
const plan = buildRollbackPlan('cutover_2', 0);
expect(plan.steps.length).toBeGreaterThan(3);
});
});