refactor(sync): remove tombstone system

Tombstones were used for tracking deleted entities but are no longer
needed with the operation log architecture. This removes:

- Tombstone table from Prisma schema
- Tombstone-related methods from SyncService
- Tombstone mocks and tests from all test files
- Database migration to drop tombstones table

The operation log now handles deletions through DEL operations,
making the separate tombstone tracking redundant.
This commit is contained in:
Johannes Millan 2025-12-28 12:08:28 +01:00
parent 805327731b
commit a52b7716aa
15 changed files with 5 additions and 178 deletions

View file

@ -0,0 +1,2 @@
-- DropTable
DROP TABLE IF EXISTS "tombstones";

View file

@ -29,7 +29,6 @@ model User {
operations Operation[]
syncState UserSyncState?
devices SyncDevice[]
tombstones Tombstone[]
@@index([verificationToken])
@@map("users")
@ -89,17 +88,3 @@ model SyncDevice {
@@map("sync_devices")
}
model Tombstone {
userId Int @map("user_id")
entityType String @map("entity_type")
entityId String @map("entity_id")
deletedAt BigInt @map("deleted_at")
deletedByOpId String @map("deleted_by_op_id")
expiresAt BigInt @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, entityType, entityId])
@@index([expiresAt])
@@map("tombstones")
}

View file

@ -58,11 +58,8 @@ async function main() {
prisma.operation.deleteMany(),
prisma.userSyncState.deleteMany(),
prisma.syncDevice.deleteMany(),
prisma.tombstone.deleteMany(),
]);
Logger.info(
'Deleted all rows from sync tables (operations, sync_state, devices, tombstones)',
);
Logger.info('Deleted all rows from sync tables (operations, sync_state, devices)');
} catch (err) {
Logger.error('Failed to clear database tables:', err);
process.exit(1);
@ -123,7 +120,6 @@ async function main() {
prisma.operation.deleteMany({ where: { userId: user.id } }),
prisma.userSyncState.deleteMany({ where: { userId: user.id } }),
prisma.syncDevice.deleteMany({ where: { userId: user.id } }),
prisma.tombstone.deleteMany({ where: { userId: user.id } }),
]);
Logger.info(`Deleted sync data for user ${user.id}`);
} catch (err) {

View file

@ -10,13 +10,7 @@ export const prisma = new PrismaClient({
});
// Re-export types for convenience
export type {
User,
Operation,
UserSyncState,
SyncDevice,
Tombstone,
} from '@prisma/client';
export type { User, Operation, UserSyncState, SyncDevice } from '@prisma/client';
// Helper to disconnect on shutdown
export const disconnectDb = async (): Promise<void> => {

View file

@ -695,7 +695,6 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
const response: SyncStatusResponse = {
latestSeq,
devicesOnline,
pendingOps: 0, // Deprecated: ACK-based tracking removed
snapshotAge,
storageUsedBytes: storageInfo.storageUsedBytes,
storageQuotaBytes: storageInfo.storageQuotaBytes,

View file

@ -388,11 +388,6 @@ export class SyncService {
},
});
// Create tombstone for delete operations
if (op.opType === 'DEL' && op.entityId) {
await this.createTombstoneSync(userId, op.entityType, op.entityId, op.id, tx);
}
return {
opId: op.id,
accepted: true,
@ -427,42 +422,6 @@ export class SyncService {
}
}
private async createTombstoneSync(
userId: number,
entityType: string,
entityId: string,
deletedByOpId: string,
tx: Prisma.TransactionClient,
): Promise<void> {
const now = Date.now();
const expiresAt = now + this.config.tombstoneRetentionMs;
await tx.tombstone.upsert({
where: {
// Prisma composite key naming uses underscores; allow it here
// eslint-disable-next-line @typescript-eslint/naming-convention
userId_entityType_entityId: {
userId,
entityType,
entityId,
},
},
create: {
userId,
entityType,
entityId,
deletedAt: BigInt(now),
deletedByOpId,
expiresAt: BigInt(expiresAt),
},
update: {
deletedAt: BigInt(now),
deletedByOpId,
expiresAt: BigInt(expiresAt),
},
});
}
// === Download Operations ===
// Delegated to OperationDownloadService
@ -609,15 +568,6 @@ export class SyncService {
// === Cleanup ===
async deleteExpiredTombstones(): Promise<number> {
const result = await prisma.tombstone.deleteMany({
where: {
expiresAt: { lt: BigInt(Date.now()) },
},
});
return result.count;
}
async deleteOldSyncedOpsForAllUsers(
cutoffTime: number,
): Promise<{ totalDeleted: number; affectedUserIds: number[] }> {
@ -860,16 +810,13 @@ export class SyncService {
/**
* Delete ALL sync data for a user. Used for encryption password changes.
* Deletes operations, tombstones, devices, and resets sync state.
* Deletes operations, devices, and resets sync state.
*/
async deleteAllUserData(userId: number): Promise<void> {
await prisma.$transaction(async (tx) => {
// Delete all operations
await tx.operation.deleteMany({ where: { userId } });
// Delete all tombstones
await tx.tombstone.deleteMany({ where: { userId } });
// Delete all devices
await tx.syncDevice.deleteMany({ where: { userId } });

View file

@ -75,7 +75,6 @@ export const testRoutes = async (fastify: FastifyInstance): Promise<void> => {
prisma.operation.deleteMany({ where: { userId } }),
prisma.syncDevice.deleteMany({ where: { userId } }),
prisma.userSyncState.deleteMany({ where: { userId } }),
prisma.tombstone.deleteMany({ where: { userId } }),
]);
} else {
// Create user with isVerified=1 (skip email verification)
@ -134,7 +133,6 @@ export const testRoutes = async (fastify: FastifyInstance): Promise<void> => {
prisma.operation.deleteMany(),
prisma.syncDevice.deleteMany(),
prisma.userSyncState.deleteMany(),
prisma.tombstone.deleteMany(),
prisma.user.deleteMany(),
]);

View file

@ -108,19 +108,6 @@ vi.mock('../src/db', async () => {
return result;
}),
},
tombstone: {
upsert: vi.fn().mockImplementation(async (args: any) => {
const key = `${args.where.userId_entityType_entityId.userId}:${args.where.userId_entityType_entityId.entityType}:${args.where.userId_entityType_entityId.entityId}`;
const result = {
...args.create,
userId: args.where.userId_entityType_entityId.userId,
entityType: args.where.userId_entityType_entityId.entityType,
entityId: args.where.userId_entityType_entityId.entityId,
};
state.tombstones.set(key, result);
return result;
}),
},
user: {
findUnique: vi.fn().mockImplementation(async (args: any) => {
return state.users.get(args.where.id) || null;

View file

@ -120,19 +120,6 @@ vi.mock('../src/db', async () => {
return result;
}),
},
tombstone: {
upsert: vi.fn().mockImplementation(async (args: any) => {
const key = `${args.where.userId_entityType_entityId.userId}:${args.where.userId_entityType_entityId.entityType}:${args.where.userId_entityType_entityId.entityId}`;
const result = {
...args.create,
userId: args.where.userId_entityType_entityId.userId,
entityType: args.where.userId_entityType_entityId.entityType,
entityId: args.where.userId_entityType_entityId.entityId,
};
state.tombstones.set(key, result);
return result;
}),
},
user: {
findUnique: vi.fn().mockImplementation(async (args: any) => {
return state.users.get(args.where.id) || null;
@ -186,10 +173,6 @@ vi.mock('../src/db', async () => {
syncDevice: {
findMany: vi.fn().mockImplementation(async () => []),
},
tombstone: {
findMany: vi.fn().mockImplementation(async () => []),
deleteMany: vi.fn().mockImplementation(async () => ({ count: 0 })),
},
user: {
findUnique: vi.fn().mockImplementation(async (args: any) => {
return state.users.get(args.where.id) || null;

View file

@ -884,18 +884,6 @@ describe('Multi-Client Sync Integration', () => {
expect(ops[0].op.opType).toBe('CRT');
expect(ops[1].op.opType).toBe('DEL');
});
it('should track tombstones for deleted entities', async () => {
const client = new SimulatedClient(app, userId);
client.createOp('CRT', 'TASK', 'task-1', { title: 'Will Delete' });
client.createOp('DEL', 'TASK', 'task-1', null);
await client.upload();
const service = getSyncService();
const isTombstoned = service.isTombstoned(userId, 'TASK', 'task-1');
expect(isTombstoned).toBe(true);
});
});
describe('Error Cases', () => {

View file

@ -12,7 +12,6 @@ interface TestData {
operations: Map<string, any>;
syncDevices: Map<string, any>;
userSyncStates: Map<number, any>;
tombstones: Map<string, any>;
}
let testData: TestData = {
@ -20,7 +19,6 @@ let testData: TestData = {
operations: new Map(),
syncDevices: new Map(),
userSyncStates: new Map(),
tombstones: new Map(),
};
let serverSeqCounter = 0;
@ -44,15 +42,6 @@ const createMockDb = () => {
if (sql.includes('UPDATE sync_devices SET last_seen_at')) {
return { changes: 1 };
}
if (sql.includes('INSERT INTO tombstones')) {
const [userId, entityType, entityId] = args;
testData.tombstones.set(`${userId}:${entityType}:${entityId}`, {
userId,
entityType,
entityId,
});
return { changes: 1 };
}
return { changes: 0 };
},
get: (...args: any[]) => {
@ -86,7 +75,6 @@ export const initDb = (dataPath: string, inMemory: boolean = false) => {
operations: new Map(),
syncDevices: new Map(),
userSyncStates: new Map(),
tombstones: new Map(),
};
serverSeqCounter = 0;
mockDb = createMockDb();
@ -173,18 +161,6 @@ vi.mock('../src/db', () => {
count: vi.fn().mockResolvedValue(1),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
tombstone: {
upsert: vi.fn().mockImplementation(async (args: any) => {
const key = `${args.where.userId_entityType_entityId.userId}:${args.where.userId_entityType_entityId.entityType}:${args.where.userId_entityType_entityId.entityId}`;
testData.tombstones.set(key, args.create);
return args.create;
}),
findUnique: vi.fn().mockImplementation(async (args: any) => {
const key = `${args.where.userId_entityType_entityId.userId}:${args.where.userId_entityType_entityId.entityType}:${args.where.userId_entityType_entityId.entityId}`;
return testData.tombstones.get(key) || null;
}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn().mockImplementation(async (args: any) => {
return testData.users.get(args.where.id) || null;
@ -217,11 +193,6 @@ vi.mock('../src/db', () => {
count: vi.fn().mockResolvedValue(1),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
tombstone: {
upsert: vi.fn(),
findUnique: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
@ -238,7 +209,6 @@ vi.mock('../src/db', () => {
operations: new Map(),
syncDevices: new Map(),
userSyncStates: new Map(),
tombstones: new Map(),
};
serverSeqCounter = 0;
mockDb = createMockDb();
@ -264,7 +234,6 @@ beforeEach(() => {
operations: new Map(),
syncDevices: new Map(),
userSyncStates: new Map(),
tombstones: new Map(),
};
serverSeqCounter = 0;
vi.clearAllMocks();

View file

@ -106,10 +106,6 @@ vi.mock('../src/db', () => {
upsert: vi.fn().mockResolvedValue({}),
count: vi.fn().mockResolvedValue(1),
},
tombstone: {
upsert: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
},
};
return callback(tx);
}),
@ -190,11 +186,6 @@ vi.mock('../src/db', () => {
count: vi.fn().mockResolvedValue(1),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
tombstone: {
upsert: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn().mockImplementation(async (args: any) => {
return testUsers.get(args.where.id) || null;

View file

@ -84,10 +84,6 @@ vi.mock('../src/db', () => {
upsert: vi.fn().mockResolvedValue({}),
count: vi.fn().mockResolvedValue(1),
},
tombstone: {
upsert: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
},
};
return callback(tx);
}),
@ -126,11 +122,6 @@ vi.mock('../src/db', () => {
count: vi.fn().mockResolvedValue(1),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
tombstone: {
upsert: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn().mockResolvedValue({
id: 1,

View file

@ -464,7 +464,6 @@ describe('Sync Routes', () => {
const body = response.json();
expect(body.latestSeq).toBeDefined();
expect(body.devicesOnline).toBeDefined();
expect(body.pendingOps).toBeDefined();
});
it('should return 401 without authorization', async () => {

View file

@ -7,7 +7,6 @@
export const testState = {
operations: new Map<string, any>(),
syncDevices: new Map<string, any>(),
tombstones: new Map<string, any>(),
userSyncStates: new Map<number, any>(),
users: new Map<number, any>(),
serverSeqCounter: 0,
@ -16,7 +15,6 @@ export const testState = {
export function resetTestState(): void {
testState.operations = new Map();
testState.syncDevices = new Map();
testState.tombstones = new Map();
testState.userSyncStates = new Map();
testState.users = new Map();
testState.serverSeqCounter = 0;