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

+18
View File
@@ -7,6 +7,8 @@ import {
encodeCursor,
decodeCursor,
decodeCursorIssuedAt,
encodeCursorAtNow,
SYNC_DOMAINS,
type SyncCursor,
} from "../cursor";
@@ -134,3 +136,19 @@ describe("cursor issue time (sync reset)", () => {
expect(decodeCursorIssuedAt("not a token")).toBeNull();
});
});
describe("encodeCursorAtNow", () => {
it("places every domain at the instant, before any id in that millisecond", () => {
const nowMs = 1_790_000_000_123;
const token = encodeCursorAtNow(nowMs);
const cursor = decodeCursor(token);
for (const domain of SYNC_DOMAINS) {
expect(cursor?.[domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
}
expect(decodeCursorIssuedAt(token)).toBe(nowMs);
// Every cuid sorts after the tie-breaker, so a row in the same
// millisecond is delivered, not skipped.
expect("0" < "c").toBe(true);
expect("0" < "a").toBe(true);
});
});
+21
View File
@@ -13,6 +13,7 @@ import {
decodeCursor,
decodeCursorIssuedAt,
encodeCursor,
encodeCursorAtNow,
SYNC_DOMAINS,
type SyncCursor,
type SyncDomain,
@@ -108,6 +109,26 @@ function emptyChanges(): SyncChangesBody["changes"] {
};
}
/**
* `start=now` (#1339): an empty page with a cursor at `serverNow` in every
* domain, for a client that wants changes from now on (the Apple Health
* write-back) without paging through the history first. `hasMore` is false:
* the client is caught up as of `serverNow` by definition.
*/
export function syncChangesFromNow(serverNow: Date): SyncChangesBody {
annotate({
action: { name: "sync.changes.start_now" },
meta: { returned: 0 },
});
return {
serverNow: serverNow.toISOString(),
cursor: encodeCursorAtNow(serverNow.getTime()),
hasMore: false,
cursorExpired: false,
changes: emptyChanges(),
};
}
export async function pullSyncChanges(input: {
userId: string;
/** The opaque cursor the client echoed, if any. */
+16
View File
@@ -102,6 +102,22 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
return Buffer.from(json, "utf-8").toString("base64url");
}
/**
* A token positioned at `nowMs` in every domain (#1339), the same opaque
* format a drained page hands out: paging forward from it yields only what
* changes from now on, with no walk through the history first. The id `"0"`
* sorts before every cuid, so a row stamped in the very millisecond is
* delivered rather than skipped; the client's upserts are idempotent, so an
* overlap costs nothing and a gap would.
*/
export function encodeCursorAtNow(nowMs: number): string {
const cursor: SyncCursor = {};
for (const domain of SYNC_DOMAINS) {
cursor[domain] = { updatedAtMs: nowMs, id: "0" };
}
return encodeCursor(cursor, nowMs);
}
function parseWatermark(raw: unknown): DomainWatermark | null {
if (typeof raw !== "object" || raw === null) return null;
const u = (raw as { u?: unknown }).u;