mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-20 18:08:55 +00:00
Prisma operation.aggregate({ _min: { serverSeq } }) compiles to MIN()
over a `SELECT ... OFFSET 0` subquery. The OFFSET is a Postgres planner
optimization fence, so the (user_id, server_seq) index could not serve a
first-row seek and the query degraded to a per-user O(N) scan. Under a
client reconnect stampede this ran minutes inside the 60s interactive
download transaction, blowing the tx timeout (500s) and exhausting the
connection pool (cascading upload+download failures).
Replace it with findFirst ordered by serverSeq asc, which compiles to
ORDER BY server_seq ASC LIMIT 1 — a guaranteed index seek on the existing
unique (user_id, server_seq) index. Behaviour-preserving: same minSeq
value and same null-when-empty semantics.
Update both consuming specs (operation-download.service, gap-detection)
to the two-findFirst-call shape and add a regression test asserting the
indexed query is used and the aggregate path is not reintroduced.
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { uuidv7 } from 'uuidv7';
|
|
import { testState, resetTestState } from './sync.service.test-state';
|
|
import { Operation } from '../src/sync/sync.types';
|
|
|
|
// Mock the database module with Prisma mocks
|
|
vi.mock('../src/db', async () => {
|
|
const {
|
|
applyOperationSelect,
|
|
hasOperationUniqueConflict,
|
|
testState: state,
|
|
} = await import('./sync.service.test-state');
|
|
const { Prisma: PrismaModule } = await import('@prisma/client');
|
|
|
|
const createTxMock = () => ({
|
|
operation: {
|
|
create: vi.fn().mockImplementation(async (args: any) => {
|
|
if (state.operations.has(args.data.id)) {
|
|
throw new PrismaModule.PrismaClientKnownRequestError(
|
|
'Unique constraint failed',
|
|
{
|
|
code: 'P2002',
|
|
clientVersion: '5.0.0',
|
|
},
|
|
);
|
|
}
|
|
state.serverSeqCounter++;
|
|
const op = {
|
|
...args.data,
|
|
serverSeq: state.serverSeqCounter,
|
|
receivedAt: BigInt(Date.now()),
|
|
};
|
|
state.operations.set(args.data.id, op);
|
|
return op;
|
|
}),
|
|
createMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const rows = Array.isArray(args.data) ? args.data : [args.data];
|
|
let count = 0;
|
|
|
|
for (const row of rows) {
|
|
if (hasOperationUniqueConflict(state.operations, row)) {
|
|
if (args.skipDuplicates) {
|
|
continue;
|
|
}
|
|
throw new PrismaModule.PrismaClientKnownRequestError(
|
|
'Unique constraint failed',
|
|
{ code: 'P2002', clientVersion: '5.0.0' },
|
|
);
|
|
}
|
|
|
|
state.operations.set(row.id, {
|
|
...row,
|
|
receivedAt: row.receivedAt ?? BigInt(Date.now()),
|
|
});
|
|
count++;
|
|
}
|
|
|
|
return { count };
|
|
}),
|
|
findFirst: vi.fn().mockImplementation(async (args: any) => {
|
|
if (args.where?.id) {
|
|
return (
|
|
applyOperationSelect(state.operations.get(args.where.id), args.select) || null
|
|
);
|
|
}
|
|
if (args.where?.entityId && args.where?.entityType) {
|
|
const ops = Array.from(state.operations.values())
|
|
.filter(
|
|
(op: any) =>
|
|
op.userId === args.where.userId &&
|
|
op.entityId === args.where.entityId &&
|
|
op.entityType === args.where.entityType &&
|
|
(args.where.clientId?.not === undefined ||
|
|
op.clientId !== args.where.clientId.not),
|
|
)
|
|
.sort((a: any, b: any) => b.serverSeq - a.serverSeq);
|
|
return applyOperationSelect(ops[0], args.select) || null;
|
|
}
|
|
// Min-server_seq gap-detection query (replaced an aggregate({ _min })
|
|
// whose Prisma OFFSET-0 subquery defeated the index): smallest
|
|
// serverSeq for the user, bounded by serverSeq <= lte, ordered asc.
|
|
if (args.orderBy?.serverSeq === 'asc' && args.where?.userId !== undefined) {
|
|
const lte = args.where.serverSeq?.lte;
|
|
const ops = Array.from(state.operations.values())
|
|
.filter(
|
|
(op: any) =>
|
|
op.userId === args.where.userId &&
|
|
(lte === undefined || op.serverSeq <= lte),
|
|
)
|
|
.sort((a: any, b: any) => a.serverSeq - b.serverSeq);
|
|
return applyOperationSelect(ops[0], args.select) || null;
|
|
}
|
|
return null;
|
|
}),
|
|
findMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(state.operations.values());
|
|
return ops
|
|
.filter((op: any) => {
|
|
if (args.where?.userId !== undefined && args.where.userId !== op.userId)
|
|
return false;
|
|
if (
|
|
args.where?.serverSeq?.gt !== undefined &&
|
|
op.serverSeq <= args.where.serverSeq.gt
|
|
)
|
|
return false;
|
|
if (args.where?.clientId?.not && op.clientId === args.where.clientId.not)
|
|
return false;
|
|
return true;
|
|
})
|
|
.sort((a: any, b: any) => a.serverSeq - b.serverSeq)
|
|
.slice(0, args.take || 500);
|
|
}),
|
|
aggregate: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(state.operations.values()).filter(
|
|
(op: any) => args.where?.userId === op.userId,
|
|
);
|
|
if (ops.length === 0)
|
|
return { _max: { serverSeq: null }, _min: { serverSeq: null } };
|
|
const seqs = ops.map((op: any) => op.serverSeq);
|
|
return {
|
|
_max: { serverSeq: Math.max(...seqs) },
|
|
_min: { serverSeq: Math.min(...seqs) },
|
|
};
|
|
}),
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
if (args.where?.id) {
|
|
return (
|
|
applyOperationSelect(state.operations.get(args.where.id), args.select) || null
|
|
);
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
userSyncState: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return state.userSyncStates.get(args.where.userId) || null;
|
|
}),
|
|
upsert: vi.fn().mockImplementation(async (args: any) => {
|
|
const existing = state.userSyncStates.get(args.where.userId);
|
|
const result = existing
|
|
? { ...existing, ...args.update }
|
|
: { userId: args.where.userId, ...args.create };
|
|
state.userSyncStates.set(args.where.userId, result);
|
|
return result;
|
|
}),
|
|
update: vi.fn().mockImplementation(async (args: any) => {
|
|
const existing = state.userSyncStates.get(args.where.userId);
|
|
if (existing) {
|
|
const updated = { ...existing };
|
|
for (const [key, value] of Object.entries(args.data)) {
|
|
if (typeof value === 'object' && value !== null && 'increment' in value) {
|
|
updated[key] =
|
|
(existing[key] || 0) + (value as { increment: number }).increment;
|
|
} else if (
|
|
typeof value === 'object' &&
|
|
value !== null &&
|
|
'decrement' in value
|
|
) {
|
|
updated[key] =
|
|
(existing[key] || 0) - (value as { decrement: number }).decrement;
|
|
} else {
|
|
updated[key] = value;
|
|
}
|
|
}
|
|
state.userSyncStates.set(args.where.userId, updated);
|
|
return updated;
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
syncDevice: {
|
|
upsert: vi.fn().mockImplementation(async (args: any) => {
|
|
const key = `${args.where.userId_clientId.userId}:${args.where.userId_clientId.clientId}`;
|
|
const result = {
|
|
...args.create,
|
|
...args.update,
|
|
userId: args.where.userId_clientId.userId,
|
|
clientId: args.where.userId_clientId.clientId,
|
|
};
|
|
state.syncDevices.set(key, result);
|
|
return result;
|
|
}),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return state.users.get(args.where.id) || null;
|
|
}),
|
|
},
|
|
// Upload transaction writes the storage counter atomically via $executeRaw.
|
|
$executeRaw: vi.fn().mockResolvedValue(0),
|
|
});
|
|
|
|
return {
|
|
prisma: {
|
|
$transaction: vi
|
|
.fn()
|
|
.mockImplementation(async (callback: any) => callback(createTxMock())),
|
|
userSyncState: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return state.userSyncStates.get(args.where.userId) || null;
|
|
}),
|
|
},
|
|
operation: {
|
|
findMany: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(state.operations.values());
|
|
return ops
|
|
.filter((op: any) => {
|
|
if (args.where?.userId !== undefined && args.where.userId !== op.userId)
|
|
return false;
|
|
if (
|
|
args.where?.serverSeq?.gt !== undefined &&
|
|
op.serverSeq <= args.where.serverSeq.gt
|
|
)
|
|
return false;
|
|
if (args.where?.clientId?.not && op.clientId === args.where.clientId.not)
|
|
return false;
|
|
return true;
|
|
})
|
|
.sort((a: any, b: any) => a.serverSeq - b.serverSeq)
|
|
.slice(0, args.take || 500);
|
|
}),
|
|
aggregate: vi.fn().mockImplementation(async (args: any) => {
|
|
const ops = Array.from(state.operations.values()).filter(
|
|
(op: any) => args.where?.userId === op.userId,
|
|
);
|
|
if (ops.length === 0)
|
|
return { _max: { serverSeq: null }, _min: { serverSeq: null } };
|
|
const seqs = ops.map((op: any) => op.serverSeq);
|
|
return {
|
|
_max: { serverSeq: Math.max(...seqs) },
|
|
_min: { serverSeq: Math.min(...seqs) },
|
|
};
|
|
}),
|
|
deleteMany: vi.fn().mockImplementation(async () => ({ count: 0 })),
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
if (args.where?.id) {
|
|
return state.operations.get(args.where.id) || null;
|
|
}
|
|
return null;
|
|
}),
|
|
},
|
|
syncDevice: {
|
|
findMany: vi.fn().mockImplementation(async () => []),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn().mockImplementation(async (args: any) => {
|
|
return state.users.get(args.where.id) || null;
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
import { initSyncService, getSyncService } from '../src/sync/sync.service';
|
|
|
|
describe('Gap Detection', () => {
|
|
const userId = 1;
|
|
const clientA = 'client-a';
|
|
const clientB = 'client-b';
|
|
|
|
const createOp = (clientId: string, entityId: string): Operation => ({
|
|
id: uuidv7(),
|
|
clientId,
|
|
actionType: 'ADD_TASK',
|
|
opType: 'CRT',
|
|
entityType: 'TASK',
|
|
entityId,
|
|
payload: { title: 'Test' },
|
|
vectorClock: { [clientId]: 1 },
|
|
timestamp: Date.now(),
|
|
schemaVersion: 1,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetTestState();
|
|
testState.users.set(userId, {
|
|
id: userId,
|
|
email: 'test@test.com',
|
|
passwordHash: 'hash',
|
|
isVerified: true,
|
|
createdAt: new Date(),
|
|
});
|
|
initSyncService();
|
|
});
|
|
|
|
// Helper to delete operations from test state (simulates db cleanup)
|
|
const deleteOperations = (filter: (op: any) => boolean) => {
|
|
for (const [id, op] of testState.operations.entries()) {
|
|
if (filter(op)) {
|
|
testState.operations.delete(id);
|
|
}
|
|
}
|
|
};
|
|
|
|
describe('Basic Gap Detection', () => {
|
|
it('should not detect gap on first sync (sinceSeq = 0)', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload some operations
|
|
const ops = [createOp(clientA, 'task-1'), createOp(clientA, 'task-2')];
|
|
await service.uploadOps(userId, clientA, ops);
|
|
|
|
// First sync from sinceSeq = 0 should never have a gap
|
|
const result = await service.getOpsSinceWithSeq(userId, 0);
|
|
expect(result.gapDetected).toBe(false);
|
|
expect(result.ops.length).toBe(2);
|
|
expect(result.latestSeq).toBe(2);
|
|
});
|
|
|
|
it('should not detect gap when operations are continuous', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload 3 operations
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-2')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-3')]);
|
|
|
|
// Client syncs from seq 1, should get ops 2 and 3 with no gap
|
|
const result = await service.getOpsSinceWithSeq(userId, 1);
|
|
expect(result.gapDetected).toBe(false);
|
|
expect(result.ops.length).toBe(2);
|
|
expect(result.ops[0].serverSeq).toBe(2);
|
|
expect(result.ops[1].serverSeq).toBe(3);
|
|
});
|
|
|
|
it('should detect gap when seq jump occurs due to purged operations', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload operations
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-2')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-3')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-4')]);
|
|
|
|
// Delete seq 2 and 3 to create a gap in the middle
|
|
deleteOperations(
|
|
(op) => op.userId === userId && (op.serverSeq === 2 || op.serverSeq === 3),
|
|
);
|
|
|
|
// Client syncs from seq 1, expects seq 2 next but gets seq 4
|
|
const result = await service.getOpsSinceWithSeq(userId, 1);
|
|
expect(result.gapDetected).toBe(true);
|
|
expect(result.ops.length).toBe(1);
|
|
expect(result.ops[0].serverSeq).toBe(4);
|
|
});
|
|
|
|
it('should detect gap when requested sinceSeq is older than retained operations', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload operations
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-2')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-3')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-4')]);
|
|
|
|
// Delete first two operations (simulating retention cleanup)
|
|
deleteOperations((op) => op.userId === userId && op.serverSeq <= 2);
|
|
|
|
// Client syncs from seq 1, but minSeq is now 3
|
|
const result = await service.getOpsSinceWithSeq(userId, 1);
|
|
expect(result.gapDetected).toBe(true);
|
|
expect(result.ops.length).toBe(2); // seqs 3 and 4
|
|
expect(result.ops[0].serverSeq).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('excludeClient Parameter', () => {
|
|
it('should exclude operations from specified client', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Client A uploads
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
// Client B uploads
|
|
await service.uploadOps(userId, clientB, [createOp(clientB, 'task-2')]);
|
|
// Client A uploads again
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-3')]);
|
|
|
|
// Client A syncs excluding own ops
|
|
const result = await service.getOpsSinceWithSeq(userId, 0, clientA);
|
|
expect(result.ops.length).toBe(1);
|
|
expect(result.ops[0].op.clientId).toBe(clientB);
|
|
expect(result.ops[0].serverSeq).toBe(2);
|
|
});
|
|
|
|
it('should not flag gap when excluded client caused the seq jump', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Client A uploads op 1
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
// Client B uploads op 2
|
|
await service.uploadOps(userId, clientB, [createOp(clientB, 'task-2')]);
|
|
// Client B uploads op 3
|
|
await service.uploadOps(userId, clientB, [createOp(clientB, 'task-3')]);
|
|
|
|
// Client B syncs from seq 0 excluding own ops
|
|
// Should only get op 1, but seq jump from 1 to 4 shouldn't be flagged as gap
|
|
// because the missing ops (2, 3) are from the excluded client
|
|
const result = await service.getOpsSinceWithSeq(userId, 0, clientB);
|
|
expect(result.ops.length).toBe(1);
|
|
expect(result.ops[0].serverSeq).toBe(1);
|
|
// Gap detection is disabled when excludeClient is specified
|
|
expect(result.gapDetected).toBe(false);
|
|
});
|
|
|
|
it('should detect gap via minSeq check even with excludeClient', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload ops from both clients
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientB, [createOp(clientB, 'task-2')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-3')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-4')]);
|
|
|
|
// Delete ops 1 and 2
|
|
deleteOperations((op) => op.userId === userId && op.serverSeq <= 2);
|
|
|
|
// Client B syncs from seq 1, excluding own ops
|
|
// sinceSeq=1, minSeq=3, so 1 < 3-1=2 is true -> gap detected
|
|
const result = await service.getOpsSinceWithSeq(userId, 1, clientB);
|
|
expect(result.gapDetected).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty database gracefully', async () => {
|
|
const service = getSyncService();
|
|
|
|
// No operations uploaded yet
|
|
const result = await service.getOpsSinceWithSeq(userId, 0);
|
|
expect(result.gapDetected).toBe(false);
|
|
expect(result.ops.length).toBe(0);
|
|
expect(result.latestSeq).toBe(0);
|
|
});
|
|
|
|
it('should detect gap when client is ahead of server (server reset scenario)', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload one op
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
|
|
// Client claims to be at seq 100 (impossible - indicates server was reset)
|
|
const result = await service.getOpsSinceWithSeq(userId, 100);
|
|
expect(result.gapDetected).toBe(true); // Gap detected: client ahead of server
|
|
expect(result.ops.length).toBe(0);
|
|
expect(result.latestSeq).toBe(1);
|
|
});
|
|
|
|
it('should respect limit parameter', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload 10 operations
|
|
for (let i = 1; i <= 10; i++) {
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, `task-${i}`)]);
|
|
}
|
|
|
|
// Request with limit 3
|
|
const result = await service.getOpsSinceWithSeq(userId, 0, undefined, 3);
|
|
expect(result.ops.length).toBe(3);
|
|
expect(result.ops[0].serverSeq).toBe(1);
|
|
expect(result.ops[2].serverSeq).toBe(3);
|
|
expect(result.latestSeq).toBe(10);
|
|
expect(result.gapDetected).toBe(false);
|
|
});
|
|
|
|
it('should not detect gap when sinceSeq = 0 (first sync)', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload and delete to create gap at seq 1
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-2')]);
|
|
deleteOperations((op) => op.userId === userId && op.serverSeq === 1);
|
|
|
|
// sinceSeq = 0 skips gap detection entirely (first sync case)
|
|
// The logic is: if (sinceSeq > 0 && latestSeq > 0) { ... }
|
|
const result = await service.getOpsSinceWithSeq(userId, 0);
|
|
expect(result.gapDetected).toBe(false);
|
|
expect(result.ops.length).toBe(1);
|
|
expect(result.ops[0].serverSeq).toBe(2);
|
|
});
|
|
|
|
it('should handle single operation in database', async () => {
|
|
const service = getSyncService();
|
|
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
|
|
// Sync from 0
|
|
const result0 = await service.getOpsSinceWithSeq(userId, 0);
|
|
expect(result0.gapDetected).toBe(false);
|
|
expect(result0.ops.length).toBe(1);
|
|
|
|
// Sync from 1 (should get nothing, no gap)
|
|
const result1 = await service.getOpsSinceWithSeq(userId, 1);
|
|
expect(result1.gapDetected).toBe(false);
|
|
expect(result1.ops.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Transaction Isolation', () => {
|
|
it('should return consistent latestSeq with returned operations', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload some operations
|
|
for (let i = 1; i <= 5; i++) {
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, `task-${i}`)]);
|
|
}
|
|
|
|
// The returned latestSeq should reflect the actual state at query time
|
|
const result = await service.getOpsSinceWithSeq(userId, 0);
|
|
expect(result.latestSeq).toBe(5);
|
|
expect(result.ops.length).toBe(5);
|
|
|
|
// If we had 5 ops and latestSeq is 5, the last op should have serverSeq 5
|
|
expect(result.ops[4].serverSeq).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('User Isolation', () => {
|
|
it('should only consider operations from requesting user', async () => {
|
|
const service = getSyncService();
|
|
const userId2 = 2;
|
|
|
|
// Create second user
|
|
testState.users.set(userId2, {
|
|
id: userId2,
|
|
email: 'test2@test.com',
|
|
passwordHash: 'hash',
|
|
isVerified: true,
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
// User 1 uploads
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-1')]);
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, 'task-2')]);
|
|
|
|
// User 2 uploads
|
|
await service.uploadOps(userId2, clientB, [createOp(clientB, 'task-1')]);
|
|
|
|
// User 2's first sync should have no gap
|
|
const result = await service.getOpsSinceWithSeq(userId2, 0);
|
|
expect(result.gapDetected).toBe(false);
|
|
expect(result.ops.length).toBe(1);
|
|
expect(result.latestSeq).toBe(1); // User 2 has seq 1 only
|
|
});
|
|
});
|
|
|
|
describe('Gap Detection with minSeq', () => {
|
|
it('should correctly calculate minSeq after cleanup', async () => {
|
|
const service = getSyncService();
|
|
|
|
// Upload 5 operations
|
|
for (let i = 1; i <= 5; i++) {
|
|
await service.uploadOps(userId, clientA, [createOp(clientA, `task-${i}`)]);
|
|
}
|
|
|
|
// Delete first 3 operations
|
|
deleteOperations((op) => op.userId === userId && op.serverSeq <= 3);
|
|
|
|
// Now minSeq should be 4
|
|
// Client requesting from seq 2 should detect gap
|
|
const result = await service.getOpsSinceWithSeq(userId, 2);
|
|
expect(result.gapDetected).toBe(true);
|
|
|
|
// Client requesting from seq 3 should also detect gap (3 < 4 - 1 = 3 is false, but first op is 4 not 4)
|
|
const result2 = await service.getOpsSinceWithSeq(userId, 3);
|
|
// sinceSeq=3, minSeq=4, 3 < 4-1=3 is false, so no gap from minSeq
|
|
// But first op is seq 4, which is 3+1=4, so no gap from first op check either
|
|
expect(result2.gapDetected).toBe(false);
|
|
expect(result2.ops[0].serverSeq).toBe(4);
|
|
});
|
|
});
|
|
});
|