feat(sync): let a client start the delta feed at the present

GET /api/sync/changes?start=now answers an empty page whose cursor sits at
serverNow in every domain, in the same opaque format the feed hands out.
A client that only needs what changes from now on, such as the Apple
Health write-back, pages forward from it instead of draining the whole
history first. start and cursor together are refused with 422.

Refs #1339
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 21:24:12 +01:00
1 parent 59434cd463
commit a79fc4168a
9 files changed
+351 -5

No files matched your search

@@ -0,0 +1,100 @@
/**
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
*
* The answer is an empty page whose cursor sits at `serverNow` in every
* domain, in the feed's own opaque format, and nothing is read: the point is
* to skip the history walk, so a read here would be the bug. The database is
* deliberately not mocked and not reachable: any read would fail the request,
* so the 200 below is itself the proof that none happened.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import {
decodeCursor,
decodeCursorIssuedAt,
SYNC_DOMAINS,
} from "@/lib/sync/cursor";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function req(query: string): NextRequest {
return new NextRequest(`http://localhost/api/sync/changes${query}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(
SESSION_OK as Awaited<ReturnType<typeof getSession>>,
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as Awaited<ReturnType<typeof checkRateLimit>>);
});
describe("GET /api/sync/changes?start=now", () => {
it("answers an empty page with a cursor at serverNow in every domain", async () => {
const before = Date.now();
const res = await GET(req("?start=now"));
const after = Date.now();
expect(res.status).toBe(200);
const { data } = (await res.json()) as {
data: {
serverNow: string;
cursor: string;
hasMore: boolean;
cursorExpired: boolean;
changes: Record<string, { upserts: unknown[]; tombstones: unknown[] }>;
};
};
expect(data.hasMore).toBe(false);
expect(data.cursorExpired).toBe(false);
expect(Object.keys(data.changes).sort()).toEqual([...SYNC_DOMAINS].sort());
for (const block of Object.values(data.changes)) {
expect(block).toEqual({ upserts: [], tombstones: [] });
}
const nowMs = Date.parse(data.serverNow);
expect(nowMs).toBeGreaterThanOrEqual(before);
expect(nowMs).toBeLessThanOrEqual(after);
const cursor = decodeCursor(data.cursor);
expect(cursor).not.toBeNull();
for (const domain of SYNC_DOMAINS) {
expect(cursor![domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
}
expect(decodeCursorIssuedAt(data.cursor)).toBe(nowMs);
});
it("refuses start together with a cursor, and any start value but now", async () => {
const both = await GET(req("?start=now&cursor=abc"));
expect(both.status).toBe(422);
const other = await GET(req("?start=oldest"));
expect(other.status).toBe(422);
});
it("still requires a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req("?start=now"));
expect(res.status).toBe(401);
});
});
+23 -5
View File
@@ -43,6 +43,11 @@
* cycle tracking off gets both blocks empty (never a 403 — see the
* gate note in the handler) and their cursor watermarks untouched.
*
* `start=now` (#1339) answers an empty page whose cursor sits at
* `serverNow` in every domain, in the same opaque format, so a client that
* only wants what changes from now on (the Apple Health write-back) pages
* forward from there instead of draining the whole history first.
*
* `apiHandler` + `requireAuth` (cookie OR Bearer; iOS uses Bearer).
* Read-only — no idempotency, no write side-effect (unlike the legacy
* `/api/sync/state` checkpoint bump). The cursor is owned by the client.
@@ -59,15 +64,23 @@ import {
sanitiseZodIssues,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { pullSyncChanges } from "@/lib/sync/changes/pull";
import { pullSyncChanges, syncChangesFromNow } from "@/lib/sync/changes/pull";
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
const querySchema = z.object({
cursor: z.string().min(1).max(2048).optional(),
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
});
// `start=now` (#1339) asks for a cursor at the present instead of a page;
// it contradicts an echoed cursor, so the two together are refused.
const querySchema = z
.object({
cursor: z.string().min(1).max(2048).optional(),
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
start: z.literal("now").optional(),
})
.refine((q) => !(q.start && q.cursor), {
message: "start and cursor are mutually exclusive",
path: ["start"],
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -83,6 +96,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = querySchema.safeParse({
cursor: url.searchParams.get("cursor") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
start: url.searchParams.get("start") ?? undefined,
});
if (!parsed.success) {
return apiValidationError(
@@ -92,6 +106,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
if (parsed.data.start === "now") {
return apiSuccess(syncChangesFromNow(new Date()));
}
return apiSuccess(
await pullSyncChanges({
userId: user.id,