mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
59434cd463
commit
a79fc4168a
9 files changed
+351
-5
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user