feat(notification): recover notification since last known event on client resume
This commit is contained in:
@@ -16,15 +16,26 @@ import type {
|
||||
UnreadCountResponse
|
||||
} from '$lib/api/types';
|
||||
|
||||
/** List newest-first. Optional `unread` filter, `before` cursor, `limit` cap. */
|
||||
/**
|
||||
* List newest-first. All filters are optional and additive:
|
||||
* - `unread` — only rows with `read_at IS NULL`
|
||||
* - `before` — older-than cursor for "load older page" pagination
|
||||
* - `after` — newer-than cursor for delta catch-up on WS reconnect
|
||||
* or tab reactivation (dedup handled at the store layer
|
||||
* via `mergeById`, since the WS push and the delta fetch
|
||||
* can race on the same row)
|
||||
* - `limit` — server-side clamp at 500 rows
|
||||
*/
|
||||
export async function listNotifications(opts?: {
|
||||
unread?: boolean;
|
||||
before?: string;
|
||||
after?: string;
|
||||
limit?: number;
|
||||
}): Promise<NotificationListResponse> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts?.unread) q.set('unread', 'true');
|
||||
if (opts?.before) q.set('before', opts.before);
|
||||
if (opts?.after) q.set('after', opts.after);
|
||||
if (opts?.limit !== undefined) q.set('limit', String(opts.limit));
|
||||
const suffix = q.toString();
|
||||
return apiJson<NotificationListResponse>(`/api/notifications${suffix ? `?${suffix}` : ''}`);
|
||||
|
||||
@@ -9,9 +9,33 @@
|
||||
*
|
||||
* Message-bus contract: the FE subscribes to `user:{me}:notifications`
|
||||
* (auto-subscribed server-side on WS session open — no `rt.subscribe`
|
||||
* frame needed from the client) and refetches the row list whenever
|
||||
* a `notification_received` event arrives. The DB is truth; the bus
|
||||
* event just says "there's new data, refresh".
|
||||
* frame needed from the client) and refetches on every push. The DB
|
||||
* is truth; the bus event is a cache-invalidation hint.
|
||||
*
|
||||
* # Delta catch-up + dedup
|
||||
*
|
||||
* Two paths can deliver the SAME row and must not double-count it:
|
||||
*
|
||||
* 1. **WS live push** — `notification_received` event → calls
|
||||
* `refreshDelta(#lastReceivedAt)` which fetches
|
||||
* `?after=<lastReceivedAt>&limit=100`, merges the result into the
|
||||
* reactive list.
|
||||
* 2. **Reconnect catch-up** — after a grace-close (tab idle > 60 s)
|
||||
* or a network drop, the WS reopens and `onReconnect` fires the
|
||||
* same `refreshDelta(#lastReceivedAt)`. This backfills rows that
|
||||
* landed while the socket was closed.
|
||||
*
|
||||
* The race: a NEW notification created after the reconnect but
|
||||
* before the delta fetch returns lands via BOTH paths — WS push
|
||||
* (delta fetch A) and reconnect (delta fetch B). Dedup lives in
|
||||
* `mergeById`: incoming rows keyed on `id` displace any existing
|
||||
* entry with the same id, so the row appears exactly once. Server
|
||||
* `read_at` always wins over local because incoming replaces.
|
||||
*
|
||||
* `#lastReceivedAt` is the newest `created_at` we've observed. It
|
||||
* feeds every delta fetch. Initial `refresh()` seeds it from the
|
||||
* newest returned row; subsequent merges update it to the newest of
|
||||
* the incoming set.
|
||||
*/
|
||||
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
@@ -28,11 +52,37 @@ import log from 'loglevel';
|
||||
|
||||
const bellLog = log.getLogger('oxi:notifications');
|
||||
|
||||
/**
|
||||
* Merge `incoming` rows into `existing`, deduplicating on `id`.
|
||||
* Where an id appears in both, the incoming (fresh-from-server)
|
||||
* copy wins — so a `read_at` flip visible in `incoming` correctly
|
||||
* overrides a stale local unread state. Result stays sorted
|
||||
* newest-first by `created_at`.
|
||||
*
|
||||
* Exported for the unit tests to exercise the race semantics
|
||||
* without spinning up a full store.
|
||||
*/
|
||||
export function mergeById(existing: Notification[], incoming: Notification[]): Notification[] {
|
||||
if (incoming.length === 0) return existing;
|
||||
// Local lookup set — pure function, no reactive state involved,
|
||||
// so `SvelteSet` would add allocations without buying anything.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const incomingIds = new Set(incoming.map((n) => n.id));
|
||||
const kept = existing.filter((n) => !incomingIds.has(n.id));
|
||||
// String compare of ISO-8601 UTC timestamps sorts identically
|
||||
// to Date compare — cheaper, no allocation per row.
|
||||
return [...incoming, ...kept].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
}
|
||||
|
||||
class NotificationsStore {
|
||||
#items = $state<Notification[]>([]);
|
||||
#unread = $state<number>(0);
|
||||
#loading = $state<boolean>(false);
|
||||
#error = $state<string | null>(null);
|
||||
/** Newest `created_at` we've observed, ISO 8601. Feeds the
|
||||
* `?after=…` cursor on delta fetches. `null` until the first
|
||||
* successful `refresh()` seeds it. */
|
||||
#lastReceivedAt: string | null = null;
|
||||
|
||||
get items(): Notification[] {
|
||||
return this.#items;
|
||||
@@ -48,8 +98,9 @@ class NotificationsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the newest page + refresh the badge count. Idempotent —
|
||||
* safe to call on every bus push, on mount, on visibility return.
|
||||
* Full refresh — replaces the local list with the newest page
|
||||
* from the server. Used on initial mount + as fallback when a
|
||||
* delta fetch fails or a mutation reconciliation runs.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
this.#loading = true;
|
||||
@@ -57,6 +108,7 @@ class NotificationsStore {
|
||||
const res = await listNotifications({ limit: 50 });
|
||||
this.#items = res.items;
|
||||
this.#unread = res.unread_count;
|
||||
this.#lastReceivedAt = res.items[0]?.created_at ?? this.#lastReceivedAt;
|
||||
this.#error = null;
|
||||
} catch (e) {
|
||||
this.#error = e instanceof Error ? e.message : String(e);
|
||||
@@ -66,6 +118,48 @@ class NotificationsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delta fetch — pulls only rows strictly newer than
|
||||
* `#lastReceivedAt` (or does nothing if we've never fetched yet;
|
||||
* the caller should fall back to `refresh()` in that case).
|
||||
* Merges via `mergeById` so a concurrent WS push and reconnect
|
||||
* catch-up can't double-count a row that landed twice.
|
||||
*
|
||||
* Silent no-op when the server returns 0 rows — we're already in
|
||||
* sync. Updates `#lastReceivedAt` to the newest of the merged set.
|
||||
*/
|
||||
async refreshDelta(): Promise<void> {
|
||||
if (this.#lastReceivedAt === null) {
|
||||
// Never fetched — fall back to a full refresh so the
|
||||
// caller doesn't need to distinguish the two cases.
|
||||
return this.refresh();
|
||||
}
|
||||
try {
|
||||
// `limit: 100` sized to cover realistic bell traffic per
|
||||
// hour without paginating; a rare heavy sender who blows
|
||||
// past 100 in one gap still gets 100 newest and the DB
|
||||
// row count (unread badge) stays authoritative.
|
||||
const res = await listNotifications({
|
||||
after: this.#lastReceivedAt,
|
||||
limit: 100
|
||||
});
|
||||
if (res.items.length > 0) {
|
||||
this.#items = mergeById(this.#items, res.items);
|
||||
// Newest of merged set — take the first item's
|
||||
// created_at since the result is sorted DESC.
|
||||
this.#lastReceivedAt = res.items[0].created_at;
|
||||
}
|
||||
// unread_count is the authoritative live server count —
|
||||
// always update it even when the delta was empty (a row
|
||||
// could have been mark-read'd on another device).
|
||||
this.#unread = res.unread_count;
|
||||
this.#error = null;
|
||||
} catch (e) {
|
||||
this.#error = e instanceof Error ? e.message : String(e);
|
||||
bellLog.warn('notifications delta failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Badge-only fast path — avoids fetching payloads. */
|
||||
async refreshBadge(): Promise<void> {
|
||||
try {
|
||||
@@ -127,6 +221,7 @@ class NotificationsStore {
|
||||
reset(): void {
|
||||
this.#items = [];
|
||||
this.#unread = 0;
|
||||
this.#lastReceivedAt = null;
|
||||
this.#error = null;
|
||||
}
|
||||
}
|
||||
@@ -135,13 +230,14 @@ class NotificationsStore {
|
||||
export const notifications = new NotificationsStore();
|
||||
|
||||
/**
|
||||
* Wire the bell into a component's lifecycle. Fires an initial fetch
|
||||
* on mount, subscribes to `user:{me}:notifications` for live pushes,
|
||||
* refetches on reconnect (bus events lost during outage window).
|
||||
* Wire the bell into a component's lifecycle. Fires an initial full
|
||||
* fetch on mount, subscribes to `user:{me}:notifications` for live
|
||||
* pushes, delta-fetches on reconnect (backfills rows missed during
|
||||
* grace-close / network gap).
|
||||
*
|
||||
* Call once from the app root (`+layout.svelte`) — this store is
|
||||
* global. Additional callers do NOT need to re-mount; they can just
|
||||
* read `notifications.items` / `notifications.unread`.
|
||||
* Call once from the app root (`AppShell`) — this store is global.
|
||||
* Additional callers do NOT need to re-mount; they can just read
|
||||
* `notifications.items` / `notifications.unread`.
|
||||
*/
|
||||
export function useNotifications(): void {
|
||||
$effect(() => {
|
||||
@@ -165,10 +261,13 @@ export function useNotifications(): void {
|
||||
`user:${userId}:notifications`,
|
||||
(params) => {
|
||||
if (params.event === 'notification_received') {
|
||||
// Bus event carries only the poke. Refetch the
|
||||
// list — cheap, gives us the new row with its
|
||||
// full payload from truth.
|
||||
void notifications.refresh();
|
||||
// Bus event carries only the poke. Delta-fetch
|
||||
// from `#lastReceivedAt` — cheap when the store
|
||||
// is caught up, brings the new row with its full
|
||||
// payload from truth. Dedup via `mergeById`
|
||||
// handles the race with an in-flight reconnect
|
||||
// catch-up returning the same row.
|
||||
void notifications.refreshDelta();
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -179,9 +278,17 @@ export function useNotifications(): void {
|
||||
);
|
||||
|
||||
const releaseReconnect = messageBus.onReconnect(() => {
|
||||
// A push we missed during the outage window is only
|
||||
// recoverable by rereading the DB.
|
||||
void notifications.refresh();
|
||||
// Tab was hidden > 60 s, or network dropped. WS just
|
||||
// reopened — any bus events published during the gap
|
||||
// are lost. Backfill via the `?after=<lastReceivedAt>`
|
||||
// cursor. Server's `unread_count` in the response is
|
||||
// authoritative — a mark-read on another device while
|
||||
// we were dark shows up here.
|
||||
//
|
||||
// Race with a live rt.event that lands milliseconds
|
||||
// later: `mergeById` deduplicates on `id`, so the
|
||||
// same row from both paths appears exactly once.
|
||||
void notifications.refreshDelta();
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mergeById } from './useNotifications.svelte';
|
||||
import type { Notification } from '$lib/api/types';
|
||||
|
||||
function row(id: string, created_at: string, read_at: string | null = null): Notification {
|
||||
return {
|
||||
id,
|
||||
kind: 'share_granted',
|
||||
payload: {},
|
||||
created_at,
|
||||
read_at
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeById — WS-push vs delta-fetch race dedup', () => {
|
||||
it('preserves existing when incoming is empty', () => {
|
||||
const existing = [row('a', '2026-09-11T10:00:00Z'), row('b', '2026-09-11T09:00:00Z')];
|
||||
expect(mergeById(existing, [])).toEqual(existing);
|
||||
});
|
||||
|
||||
it('appends non-overlapping incoming and sorts newest-first', () => {
|
||||
const existing = [row('b', '2026-09-11T09:00:00Z')];
|
||||
const incoming = [row('a', '2026-09-11T10:00:00Z')];
|
||||
const merged = mergeById(existing, incoming);
|
||||
expect(merged.map((n) => n.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('dedupes on id — same row from WS push and delta fetch appears once', () => {
|
||||
// Simulates the race: `x` was delivered live via rt.event
|
||||
// and appended locally, then the reconnect delta fetch
|
||||
// returns the same `x` again. Must not double it.
|
||||
const existing = [row('x', '2026-09-11T10:00:00Z')];
|
||||
const incoming = [row('x', '2026-09-11T10:00:00Z')];
|
||||
expect(mergeById(existing, incoming)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lets server value win — read_at flip visible in incoming', () => {
|
||||
// User marked `x` as read on another device. Local copy is
|
||||
// stale (still unread). The delta fetch returns the fresh
|
||||
// row with read_at populated — that must win.
|
||||
const existing = [row('x', '2026-09-11T10:00:00Z', null)];
|
||||
const incoming = [row('x', '2026-09-11T10:00:00Z', '2026-09-11T10:05:00Z')];
|
||||
const merged = mergeById(existing, incoming);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].read_at).toBe('2026-09-11T10:05:00Z');
|
||||
});
|
||||
|
||||
it('merges mixed overlap correctly', () => {
|
||||
const existing = [row('b', '2026-09-11T09:00:00Z'), row('a', '2026-09-11T08:00:00Z')];
|
||||
const incoming = [
|
||||
row('c', '2026-09-11T10:00:00Z'), // new
|
||||
row('b', '2026-09-11T09:00:00Z', '2026-09-11T09:30:00Z') // updated
|
||||
];
|
||||
const merged = mergeById(existing, incoming);
|
||||
expect(merged.map((n) => n.id)).toEqual(['c', 'b', 'a']);
|
||||
expect(merged[1].read_at).toBe('2026-09-11T09:30:00Z');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user