fix(supersync): exclude legacy REPAIR from retention cleanup pruning

The daily-cleanup fallback (used when `latestFullStateSeq` is absent, i.e.
legacy/pre-marker installs) selected the pruning boundary with a raw
`opType: { in: [SYNC_IMPORT, BACKUP_IMPORT, REPAIR] }` filter that includes
legacy REPAIR rows (`repairBaseServerSeq` NULL). Such a repair carries no
causal base cursor proving its state is current as of its seq, so pruning
history behind it can permanently drop ops committed between its logical
base and its seq for any device replaying from before it.

#8971 migrated five full-state queries to `CAUSAL_FULL_STATE_OPERATION_WHERE`
but missed this one — the single query whose result directly authorizes a
DELETE. Route it through the same causal-only predicate; a legacy-repair-only
user then yields `protectedFromSeq === null` and is skipped (no deletion),
the safe default already implemented below.

Adds a regression test whose mock `findFirst` honours the causal
`repairBaseServerSeq` predicate.
This commit is contained in:
Johannes Millan 2026-07-15 11:39:56 +02:00
parent 7e273a0e5c
commit 37bf818079
2 changed files with 78 additions and 1 deletions

View file

@ -445,7 +445,14 @@ export class StorageQuotaService {
where: {
userId: state.userId,
serverSeq: { lte: lastSnapshotSeq },
opType: { in: ['SYNC_IMPORT', 'BACKUP_IMPORT', 'REPAIR'] },
// Legacy REPAIR rows carry no causal base cursor, so they must never
// authorize history pruning (see CAUSAL_FULL_STATE_OPERATION_WHERE).
// This is the one query whose result directly authorizes a DELETE;
// it must match the five other full-state queries and the primary
// `latestFullStateSeq` marker, which are all causal-only. A
// legacy-repair-only user then yields protectedFromSeq === null →
// eligibleUsersWithoutReplayBase++ → skipped (no deletion).
...CAUSAL_FULL_STATE_OPERATION_WHERE,
},
orderBy: { serverSeq: 'desc' },
select: { serverSeq: true },

View file

@ -2806,6 +2806,76 @@ describe('SyncService', () => {
expect(freshClientOps.map((op) => op.serverSeq)).toEqual([4, 5]);
});
it('does not prune history behind a legacy REPAIR without a causal base (fallback path)', async () => {
// Regression guard: the fallback used when `latestFullStateSeq` is absent
// (legacy/pre-marker installs) must use the causal-only full-state
// predicate, like every other full-state query. A legacy REPAIR carries
// appDataComplete but no `repairBaseServerSeq` proving its state is current
// as of its seq, so it must NEVER authorize history pruning — ops between
// its logical base and its seq would be lost for a device replaying from
// before it. Before the fix this fallback used a raw opType filter that
// selected the legacy REPAIR as the prune boundary and deleted ops 13.
const service = getSyncService();
const warnSpy = vi.spyOn(Logger, 'warn').mockImplementation(() => undefined);
const cutoffTime = Date.now() - 50 * 24 * 60 * 60 * 1000;
for (let i = 1; i <= 5; i++) {
const isLegacyRepair = i === 4;
testState.operations.set(`old-op-${i}`, {
id: `old-op-${i}`,
userId,
clientId,
serverSeq: i,
actionType: isLegacyRepair ? 'LOAD_ALL_DATA' : 'ADD',
opType: isLegacyRepair ? 'REPAIR' : 'CRT',
entityType: isLegacyRepair ? 'ALL' : 'TASK',
entityId: isLegacyRepair ? null : `t${i}`,
entityIds: [],
payload: isLegacyRepair ? { appDataComplete: { TASK: {} } } : {},
vectorClock: {},
schemaVersion: 1,
clientTimestamp: BigInt(Date.now()),
receivedAt: BigInt(cutoffTime - 1),
isPayloadEncrypted: false,
syncImportReason: null,
// Legacy REPAIR = no causal base cursor.
repairBaseServerSeq: null,
});
}
// latestFullStateSeq deliberately unset → cleanup takes the fallback query
// path (the branch this fix hardens).
testState.userSyncStates.set(userId, {
userId,
lastSeq: 5,
lastSnapshotSeq: 5,
snapshotAt: BigInt(Date.now()),
});
try {
const { totalDeleted, affectedUserIds } =
await service.deleteOldSyncedOpsForAllUsers(cutoffTime);
expect(totalDeleted).toBe(0);
expect(affectedUserIds).not.toContain(userId);
// The fallback query ran (marker absent) but excluded the legacy REPAIR,
// so the user has no replay base and is skipped rather than pruned.
expect(prisma.operation.findFirst).toHaveBeenCalled();
expect(Array.from(testState.operations.keys())).toEqual([
'old-op-1',
'old-op-2',
'old-op-3',
'old-op-4',
'old-op-5',
]);
expect(warnSpy).toHaveBeenCalledWith(
'Cleanup [old-ops]: skipped 1 eligible user(s) without a full-state replay base; their operation histories were left intact.',
);
} finally {
warnSpy.mockRestore();
}
});
it('drains a single user up to the per-run budget', async () => {
const service = getSyncService();
process.env.OLD_OPS_CLEANUP_DELETE_BATCH_SIZE = '50';