Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ The kosync body plus a `position` object mapping 1:1 to the firmware's `CompactP

Response: `{"document": "...", "timestamp": 1752345678}`.

An optional `timestamp` (unix seconds) says when the position was read, for clients that sync
later than they read. The stored time, the reading timeline and "newest wins" then use it instead
of the time of the request. A time in the future is capped at now; a missing one, or one before
2000, means now. `PUT /syncs/progress` and the batch route always use the time of the request, so
a reader with an unset clock can't backdate itself.

An invalid `position` is ignored (the kosync fields still sync); a missing `position` on a later
PUT keeps the previously stored one for that device.

Expand Down
19 changes: 17 additions & 2 deletions src/routes/kosync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@ export function upsertProgress(db: DB, p: ProgressUpsert): void {
*/
export function parseProgressBody(
userId: number,
body: unknown
body: unknown,
opts: { clientTime?: boolean } = {}
): { ok: true; record: ProgressUpsert } | { ok: false; code: number; message: string } {
if (typeof body !== 'object' || body === null) {
return { ok: false, code: 2003, message: 'Invalid request' };
Expand Down Expand Up @@ -328,11 +329,25 @@ export function parseProgressBody(
progress: o.progress,
position,
metadata: parseMetadata(o.metadata),
updatedAt: nowSeconds(),
updatedAt: opts.clientTime ? clientTime(o.timestamp) : nowSeconds(),
},
};
}

// Oldest client time taken as real; anything earlier is an unset clock.
const EARLIEST_CLIENT_TIME = 946684800; // 2000-01-01

/**
* When the position was read, for clients that sync later than they read (an
* importer running hourly, say). Whole unix seconds; a time in the future is
* capped at now, and a missing or implausible one falls back to now.
*/
function clientTime(value: unknown): number {
const now = nowSeconds();
if (typeof value !== 'number' || !Number.isInteger(value) || value < EARLIEST_CLIENT_TIME) return now;
return Math.min(value, now);
}

/**
* Stores a device's progress write the way the kosync PUT does: under the
* document's canonical hash, as a position sample for fan-in replays, and fanned
Expand Down
2 changes: 1 addition & 1 deletion src/routes/v1/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async
return kosyncError(c, 403, 2003, 'Invalid request');
}
const user = c.get('user');
const parsed = parseProgressBody(user.id, body);
const parsed = parseProgressBody(user.id, body, { clientTime: true });
if (!parsed.ok) {
return kosyncError(c, 403, parsed.code, parsed.message);
}
Expand Down
39 changes: 39 additions & 0 deletions test/progress-client-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { DOC, makeTestApp, registerUser } from './helpers.js';

const READ_AT = 1767254400; // 2026-01-01T08:00:00Z

describe('client time on PUT /api/v1/progress', () => {
it('keeps when the position was read, and only on the API route', async () => {
const { app } = makeTestApp();
const { headers } = await registerUser(app);
const body = (percentage: number, device_id: string, timestamp?: unknown) =>
JSON.stringify({ document: DOC, progress: 'p', percentage, device: device_id, device_id, ...(timestamp === undefined ? {} : { timestamp }) });
const put = async (percentage: number, device_id: string, timestamp?: unknown) =>
((await (await app.request('/api/v1/progress', { method: 'PUT', headers, body: body(percentage, device_id, timestamp) })).json()) as { timestamp: number }).timestamp;
const now = () => Math.floor(Date.now() / 1000);

// A past time is stored as given, and the timeline puts the read on that day.
expect(await put(0.2, 'importer', READ_AT)).toBe(READ_AT);
const listed = (await (await app.request('/api/v1/progress', { headers })).json()) as { items: { timestamp: number }[] };
expect(listed.items[0].timestamp).toBe(READ_AT);
const activity = (await (await app.request('/api/v1/stats/activity', { headers })).json()) as { days: { day: string }[] };
expect(activity.days.map((d) => d.day)).toContain('2026-01-01');

// The future is capped at now; garbage and an unset clock mean now.
const before = now();
expect(await put(0.3, 'importer', now() + 86400)).toBeLessThanOrEqual(now());
expect(await put(0.4, 'importer', '1767254400')).toBeGreaterThanOrEqual(before);
expect(await put(0.5, 'importer', 12)).toBeGreaterThanOrEqual(before);

// An older position from one device doesn't beat a newer one from another.
await put(0.6, 'reader');
await put(0.9, 'importer', READ_AT);
const newest = (await (await app.request(`/syncs/progress/${DOC}`, { headers })).json()) as { percentage: number };
expect(newest.percentage).toBe(0.6);

// The kosync route ignores a client time.
const res = await app.request('/syncs/progress', { method: 'PUT', headers, body: body(0.7, 'reader', READ_AT) });
expect(((await res.json()) as { timestamp: number }).timestamp).toBeGreaterThanOrEqual(before);
});
});
Loading