feat: complete automatic mobile board UX
This commit is contained in:
1 parent
cf83e0d849
commit
7959c20d88
87 files changed
+2850
-570
No files matched your search
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
|
||||
describe("createRequestHandler", () => {
|
||||
test("keeps the standard result contract free of cache provenance", async () => {
|
||||
const requestHandler = createRequestHandler({
|
||||
requestAsync: vi.fn().mockResolvedValue("current"),
|
||||
});
|
||||
|
||||
const result = await requestHandler.handler({ integrationId: "first" }).getDataAsync();
|
||||
|
||||
expect(result).toMatchObject({ data: "current", timestamp: expect.any(Date) });
|
||||
expect(result).not.toHaveProperty("isStale");
|
||||
});
|
||||
|
||||
test("marks an expired cache fallback as stale while preserving its timestamp", async () => {
|
||||
const requestAsync = vi.fn().mockResolvedValueOnce("cached").mockRejectedValueOnce(new Error("offline"));
|
||||
const requestHandler = createRequestHandler({
|
||||
requestAsync,
|
||||
cacheTtlMs: 0,
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
const handler = requestHandler.handler({ integrationId: "first" });
|
||||
|
||||
const fresh = await handler.getDataWithProvenanceAsync();
|
||||
const fallback = await handler.getDataWithProvenanceAsync();
|
||||
|
||||
expect(fresh).toMatchObject({ data: "cached", isStale: false });
|
||||
expect(fallback).toEqual({
|
||||
data: "cached",
|
||||
timestamp: fresh.timestamp,
|
||||
isStale: true,
|
||||
});
|
||||
expect(requestAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("does not hide an initial error when no cache exists", async () => {
|
||||
const requestHandler = createRequestHandler({
|
||||
requestAsync: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
|
||||
await expect(requestHandler.handler({ integrationId: "first" }).getDataWithProvenanceAsync()).rejects.toThrow(
|
||||
"offline",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
type RequestResult<TData> = Pick<CacheEntry<TData>, "data" | "timestamp"> & { isStale: boolean };
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
@@ -20,21 +21,21 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
const inflight = new Map<string, Promise<RequestResult<TData>>>();
|
||||
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
},
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
handler: (input: TInput) => {
|
||||
const getDataWithProvenanceAsync = async (): Promise<RequestResult<TData>> => {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = JSON.stringify(input);
|
||||
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
return { data: cached.data, timestamp: cached.timestamp, isStale: false };
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
@@ -51,19 +52,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||
cache.set(key, entry);
|
||||
inflight.delete(key);
|
||||
return entry;
|
||||
return { data: entry.data, timestamp: entry.timestamp, isStale: false };
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
if (options.fallbackToStaleOnError && cached) {
|
||||
return cached;
|
||||
return { data: cached.data, timestamp: cached.timestamp, isStale: true };
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
async getDataAsync(): Promise<Pick<RequestResult<TData>, "data" | "timestamp">> {
|
||||
const { data, timestamp } = await getDataWithProvenanceAsync();
|
||||
return { data, timestamp };
|
||||
},
|
||||
getDataWithProvenanceAsync,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -13,4 +13,5 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getMediaReleasesAsync();
|
||||
},
|
||||
fallbackToStaleOnError: true,
|
||||
});
|
||||
Reference in new issue
Block a user