From 3a32cb2aa0e7ca8bb4a8353e274edbd46f425c64 Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 20 Aug 2026 23:56:09 +0900 Subject: [PATCH] web/api: invalidate cached reads after record writes Signed-off-by: dawn --- web/src/lib/api/cache.test.ts | 91 +++++++++++++++ web/src/lib/api/cache.ts | 23 ++++ web/src/lib/api/comment.ts | 26 +---- web/src/lib/api/graph.ts | 105 ++++++------------ web/src/lib/api/identity.ts | 11 +- web/src/lib/api/index.ts | 1 + web/src/lib/api/issue.ts | 26 +---- web/src/lib/api/profile.ts | 16 +-- web/src/lib/api/pull.ts | 47 +------- web/src/lib/api/reaction.ts | 26 +---- web/src/lib/api/settings.ts | 41 +++---- web/src/lib/api/strings.ts | 18 +-- web/src/lib/api/write.test.ts | 74 ++++++++++++ web/src/lib/api/write.ts | 71 ++++++++++++ web/src/routes/[handle]/+page.ts | 29 +++-- .../routes/[handle]/[repo]/issues/+page.ts | 45 ++++---- web/src/routes/[handle]/[repo]/pulls/+page.ts | 52 +++++---- 17 files changed, 416 insertions(+), 286 deletions(-) create mode 100644 web/src/lib/api/cache.test.ts create mode 100644 web/src/lib/api/cache.ts create mode 100644 web/src/lib/api/write.test.ts create mode 100644 web/src/lib/api/write.ts diff --git a/web/src/lib/api/cache.test.ts b/web/src/lib/api/cache.test.ts new file mode 100644 index 000000000..d96456133 --- /dev/null +++ b/web/src/lib/api/cache.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("cache", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("bypasses cache when not in browser", async () => { + vi.doMock("$app/environment", () => ({ browser: false })); + const { cached } = await import("./cache"); + + const fn = vi.fn().mockResolvedValue("result-1"); + const res1 = await cached("test-key", 30_000, fn); + const res2 = await cached("test-key", 30_000, fn); + + expect(res1).toBe("result-1"); + expect(res2).toBe("result-1"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("caches value in browser and serves from cache before ttl expires", async () => { + vi.doMock("$app/environment", () => ({ browser: true })); + const { cached } = await import("./cache"); + + const fn = vi.fn().mockResolvedValue({ hello: "world" }); + const res1 = await cached("test-key", 30_000, fn); + const res2 = await cached("test-key", 30_000, fn); + + expect(res1).toEqual({ hello: "world" }); + expect(res2).toEqual({ hello: "world" }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("refetches after ttl expires", async () => { + vi.useFakeTimers(); + vi.doMock("$app/environment", () => ({ browser: true })); + const { cached } = await import("./cache"); + + let count = 0; + const fn = vi.fn().mockImplementation(async () => ++count); + + const res1 = await cached("test-key", 30_000, fn); + expect(res1).toBe(1); + + vi.advanceTimersByTime(29_999); + const res2 = await cached("test-key", 30_000, fn); + expect(res2).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2); + const res3 = await cached("test-key", 30_000, fn); + expect(res3).toBe(2); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("cleans up cache and rethrows when loader rejects", async () => { + vi.doMock("$app/environment", () => ({ browser: true })); + const { cached } = await import("./cache"); + + const err = new Error("network error"); + const fn = vi.fn().mockRejectedValue(err); + + await expect(cached("err-key", 30_000, fn)).rejects.toThrow("network error"); + + fn.mockResolvedValueOnce("recovered"); + const res = await cached("err-key", 30_000, fn); + expect(res).toBe("recovered"); + }); + + it("clears every key", async () => { + vi.doMock("$app/environment", () => ({ browser: true })); + const { cached, clear } = await import("./cache"); + + const fnIssues = vi.fn().mockResolvedValue("issues-data"); + const fnPulls = vi.fn().mockResolvedValue("pulls-data"); + + await cached("issues:did:plc:repo:open:", 30_000, fnIssues); + await cached("pulls:did:plc:repo:open:", 30_000, fnPulls); + + clear(); + + await cached("issues:did:plc:repo:open:", 30_000, fnIssues); + await cached("pulls:did:plc:repo:open:", 30_000, fnPulls); + + expect(fnIssues).toHaveBeenCalledTimes(2); + expect(fnPulls).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/src/lib/api/cache.ts b/web/src/lib/api/cache.ts new file mode 100644 index 000000000..2e9feaf6c --- /dev/null +++ b/web/src/lib/api/cache.ts @@ -0,0 +1,23 @@ +import { browser } from "$app/environment"; + +type Entry = { value: unknown; expires: number }; +const store = new Map(); + +// browser only, a server map would leak viewer-scoped data across requests +export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { + if (!browser) return fn(); + const hit = store.get(key); + if (hit && hit.expires > Date.now()) return hit.value as T; + try { + const value = await fn(); + store.set(key, { value, expires: Date.now() + ttlMs }); + return value; + } catch (cause) { + store.delete(key); + throw cause; + } +} + +export function clear(): void { + store.clear(); +} diff --git a/web/src/lib/api/comment.ts b/web/src/lib/api/comment.ts index 6d156ede9..db1f5816d 100644 --- a/web/src/lib/api/comment.ts +++ b/web/src/lib/api/comment.ts @@ -1,12 +1,9 @@ -import { ok } from "@atcute/client"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; import type { BobbinContext, XrpcRequestInit } from "./client"; import { jsonGet } from "./_request"; import type { RecordView } from "./records"; +import { deleteRecord, putRecord } from "./write"; import type * as ShTangledFeedComment from "./lexicons/types/sh/tangled/feed/comment"; export type CommentRecord = ShTangledFeedComment.Main; @@ -18,27 +15,12 @@ export const putComment = async ( rkey: string, record: CommentRecord ): Promise> => { - const rpc = createClient(agent); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: COMMENT_COLLECTION, - rkey: rkey as RecordKey, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, COMMENT_COLLECTION, rkey, record); return { uri, cid, value: record }; }; export const deleteComment = async (agent: OAuthUserAgent, rkey: string): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection: COMMENT_COLLECTION, rkey: rkey as RecordKey } - }) - ); + await deleteRecord(agent, COMMENT_COLLECTION, rkey); }; export interface CommentListPage { diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts index 521db916b..40a2d702c 100644 --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -1,14 +1,10 @@ -import { ok } from "@atcute/client"; -import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; -import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Did, Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; import { ClientResponseError, type BobbinContext } from "./client"; import { jsonGet } from "./_request"; import { httpStatusFor } from "./load"; import { didFromUri, rkeyFromUri } from "./uri"; +import { createRecord, deleteRecord, putRecord } from "./write"; import type { VouchRecord } from "./records"; import type * as ShTangledFeedGetStar from "./lexicons/types/sh/tangled/feed/getStar"; import type * as ShTangledGraphFollow from "./lexicons/types/sh/tangled/graph/follow"; @@ -21,8 +17,6 @@ export type { VouchRecord }; const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; const VOUCH_COLLECTION = "sh.tangled.graph.vouch" as Nsid; - -// 404 means no follow exists, other errors propagate export const getFollowRkey = async ( ctx: BobbinContext, actor: string, @@ -32,7 +26,10 @@ export const getFollowRkey = async ( const { uri } = await jsonGet( ctx, "sh.tangled.graph.getFollow", - { actor: actor as Did, subject: subject as Did } satisfies ShTangledGraphGetFollow.$params + { + actor: actor as Did, + subject: subject as Did + } satisfies ShTangledGraphGetFollow.$params ); return rkeyFromUri(uri); } catch (cause) { @@ -48,10 +45,14 @@ export const getStarRkey = async ( subject: string ): Promise => { try { - const { uri } = await jsonGet(ctx, "sh.tangled.feed.getStar", { - actor: actor as Did, - subject - } satisfies ShTangledFeedGetStar.$params); + const { uri } = await jsonGet( + ctx, + "sh.tangled.feed.getStar", + { + actor: actor as Did, + subject + } satisfies ShTangledFeedGetStar.$params + ); return rkeyFromUri(uri); } catch (cause) { if (cause instanceof ClientResponseError && httpStatusFor(cause) === 404) return null; @@ -59,60 +60,21 @@ export const getStarRkey = async ( } }; -const createGenericRecord = async ( - agent: OAuthUserAgent, - collection: Nsid, - record: T -): Promise => { - const rpc = createClient(agent); - const result = await ok( - rpc.call(createRecordSchema, { - input: { repo: agent.sub, collection, record } - }) - ); - return rkeyFromUri(result.uri); -}; - -const putGenericRecord = async ( - agent: OAuthUserAgent, - collection: Nsid, - rkey: string, - record: T -): Promise => { - const rpc = createClient(agent); - const result = await ok( - rpc.call(putRecordSchema, { - input: { repo: agent.sub, collection, rkey: rkey as RecordKey, record } - }) - ); - return rkeyFromUri(result.uri); -}; - -const deleteGenericRecord = async ( - agent: OAuthUserAgent, - collection: Nsid, - rkey: string -): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection, rkey: rkey as RecordKey } - }) - ); -}; - -export const createFollow = (agent: OAuthUserAgent, subject: string): Promise => - createGenericRecord(agent, FOLLOW_COLLECTION, { +export const createFollow = async (agent: OAuthUserAgent, subject: string): Promise => { + const { uri } = await createRecord(agent, FOLLOW_COLLECTION, { $type: "sh.tangled.graph.follow", subject: subject as Did, createdAt: new Date().toISOString() }); + return rkeyFromUri(uri); +}; -export const deleteFollow = (agent: OAuthUserAgent, rkey: string): Promise => - deleteGenericRecord(agent, FOLLOW_COLLECTION, rkey); +export const deleteFollow = async (agent: OAuthUserAgent, rkey: string): Promise => { + await deleteRecord(agent, FOLLOW_COLLECTION, rkey); +}; -export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise => - createGenericRecord(agent, STAR_COLLECTION, { +export const createStar = async (agent: OAuthUserAgent, repoDid: string): Promise => { + const { uri } = await createRecord(agent, STAR_COLLECTION, { $type: "sh.tangled.feed.star", subject: { $type: "sh.tangled.feed.star#repo", @@ -120,9 +82,12 @@ export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise => - deleteGenericRecord(agent, STAR_COLLECTION, rkey); +export const deleteStar = async (agent: OAuthUserAgent, rkey: string): Promise => { + await deleteRecord(agent, STAR_COLLECTION, rkey); +}; export type VouchKind = "vouch" | "denounce"; @@ -132,22 +97,24 @@ export interface VouchInput { evidences?: string[]; } -export const putVouch = ( +export const putVouch = async ( agent: OAuthUserAgent, subject: string, input: VouchInput -): Promise => - putGenericRecord(agent, VOUCH_COLLECTION, subject, { +): Promise => { + const { uri } = await putRecord(agent, VOUCH_COLLECTION, subject, { $type: "sh.tangled.graph.vouch", kind: input.kind, ...(input.reason ? { reason: input.reason } : {}), ...(input.evidences?.length ? { evidences: input.evidences } : {}), createdAt: new Date().toISOString() }); + return rkeyFromUri(uri); +}; -export const deleteVouch = (agent: OAuthUserAgent, subject: string): Promise => - deleteGenericRecord(agent, VOUCH_COLLECTION, subject); - +export const deleteVouch = async (agent: OAuthUserAgent, subject: string): Promise => { + await deleteRecord(agent, VOUCH_COLLECTION, subject); +}; export const getViewerVouch = async ( ctx: BobbinContext, viewer: string, diff --git a/web/src/lib/api/identity.ts b/web/src/lib/api/identity.ts index eb93ffcc4..d1a7f920c 100644 --- a/web/src/lib/api/identity.ts +++ b/web/src/lib/api/identity.ts @@ -1,4 +1,5 @@ import type * as BlueMicrocosmIdentityResolveMiniDoc from "@atcute/microcosm/types/blue/microcosm/identity/resolveMiniDoc"; +import { cached } from "./cache"; import type { BobbinContext, XrpcRequestInit } from "./client"; import { jsonGet } from "./_request"; @@ -11,8 +12,9 @@ export const resolveMiniDoc = ( identifier: string, init?: XrpcRequestInit ): Promise => - jsonGet(ctx, "blue.microcosm.identity.resolveMiniDoc", { identifier }, init); - + cached(`minidoc:${identifier.toLowerCase()}`, 60_000, () => + jsonGet(ctx, "blue.microcosm.identity.resolveMiniDoc", { identifier }, init) + ); // did/handle cache with in-flight de-dupe. export class IdentityCache { readonly #ctx: BobbinContext; @@ -38,11 +40,10 @@ export class IdentityCache { } resolve(identifier: string, init?: XrpcRequestInit): Promise { - const cached = identifier.startsWith("did:") + const hit = identifier.startsWith("did:") ? this.#byDid.get(identifier) : this.#byHandle.get(identifier); - if (cached) return Promise.resolve(cached); - + if (hit) return Promise.resolve(hit); const existing = this.#inflight.get(identifier); if (existing) return existing; diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index ef778d15c..4c600879e 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -1,3 +1,4 @@ +export * from "./cache"; export * from "./client"; export * from "./pagination"; export * from "./count"; diff --git a/web/src/lib/api/issue.ts b/web/src/lib/api/issue.ts index bd654b96a..0dfc7efa7 100644 --- a/web/src/lib/api/issue.ts +++ b/web/src/lib/api/issue.ts @@ -1,9 +1,6 @@ -import { ok } from "@atcute/client"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; +import { deleteRecord, putRecord } from "./write"; import type { BobbinContext, XrpcRequestInit } from "./client"; import { jsonGet } from "./_request"; import type { RecordList, RecordView } from "./records"; @@ -20,27 +17,12 @@ export const putIssue = async ( rkey: string, record: IssueRecord ): Promise> => { - const rpc = createClient(agent); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: ISSUE_COLLECTION, - rkey: rkey as RecordKey, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, ISSUE_COLLECTION, rkey, record); return { uri, cid, value: record }; }; export const deleteIssue = async (agent: OAuthUserAgent, rkey: string): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection: ISSUE_COLLECTION, rkey: rkey as RecordKey } - }) - ); + await deleteRecord(agent, ISSUE_COLLECTION, rkey); }; export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts index e918caf4f..f057d4dfc 100644 --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -1,13 +1,13 @@ import { ok } from "@atcute/client"; import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createClient } from "$lib/auth/agent"; import { FOLLOW_VIEWER, STAR_VIEWER } from "./descriptors"; import { enrich, target } from "./enrich"; import { jsonGet } from "./_request"; import type { BobbinContext, XrpcRequestInit } from "./client"; +import { putRecord } from "./write"; import type * as ShTangledActorGetTrending from "./lexicons/types/sh/tangled/actor/getTrending"; import type { ProfileRecord } from "./records"; @@ -48,15 +48,5 @@ export const uploadProfileAvatar = async ( // the profile record lives at the fixed rkey `self`; put upserts it. export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: PROFILE_COLLECTION, - rkey: "self" as RecordKey, - record - } - }) - ); + await putRecord(agent, PROFILE_COLLECTION, "self", record); }; diff --git a/web/src/lib/api/pull.ts b/web/src/lib/api/pull.ts index cb39913cc..61fd96a68 100644 --- a/web/src/lib/api/pull.ts +++ b/web/src/lib/api/pull.ts @@ -1,11 +1,10 @@ import { ok } from "@atcute/client"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; import { mainSchema as getRecordSchema } from "@atcute/atproto/types/repo/getRecord"; import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { now as tidNow } from "@atcute/tid"; import { createClient, mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import { deleteRecord, putRecord } from "./write"; import { toResponseError } from "./_request"; import type { PullRecord, PullState, PullStatusRecord, RecordView } from "./records"; @@ -17,17 +16,7 @@ export const putPull = async ( rkey: string, record: PullRecord ): Promise> => { - const rpc = createClient(agent); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: PULL_COLLECTION, - rkey: rkey as RecordKey, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, PULL_COLLECTION, rkey, record); return { uri, cid, value: record }; }; @@ -48,17 +37,7 @@ const updatePull = async ( }) ); const record = mutate(existing.value as PullRecord); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: PULL_COLLECTION, - rkey: rkey as RecordKey, - swapRecord: existing.cid, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, PULL_COLLECTION, rkey, record, existing.cid); return { uri, cid, value: record }; }; @@ -76,12 +55,7 @@ export const resubmitPull = ( updatePull(agent, rkey, (record) => ({ ...record, versions: [...(record.versions ?? []), version] })); export const deletePull = async (agent: OAuthUserAgent, rkey: string): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection: PULL_COLLECTION, rkey: rkey as RecordKey } - }) - ); + await deleteRecord(agent, PULL_COLLECTION, rkey); }; /** @@ -101,21 +75,10 @@ export const putPullStatus = async ( pull: string, status: PullState ): Promise> => { - const rpc = createClient(agent); const record = pullStatusRecord(pull, status); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: PULL_STATUS_COLLECTION, - rkey: tidNow() as RecordKey, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, PULL_STATUS_COLLECTION, tidNow(), record); return { uri, cid, value: record }; }; - const KEEP_COMMIT_NSID = "sh.tangled.git.keepCommit"; export interface KeepCommitInput { diff --git a/web/src/lib/api/reaction.ts b/web/src/lib/api/reaction.ts index e224c76fd..25c302eeb 100644 --- a/web/src/lib/api/reaction.ts +++ b/web/src/lib/api/reaction.ts @@ -1,12 +1,9 @@ -import { ok } from "@atcute/client"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; import type { BobbinContext, XrpcRequestInit } from "./client"; import { jsonGet } from "./_request"; import type { RecordView } from "./records"; +import { deleteRecord, putRecord } from "./write"; import type * as ShTangledFeedReaction from "./lexicons/types/sh/tangled/feed/reaction"; export type ReactionRecord = ShTangledFeedReaction.Main; @@ -18,27 +15,12 @@ export const putReaction = async ( rkey: string, record: ReactionRecord ): Promise> => { - const rpc = createClient(agent); - const { uri, cid } = await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: REACTION_COLLECTION, - rkey: rkey as RecordKey, - record - } - }) - ); + const { uri, cid } = await putRecord(agent, REACTION_COLLECTION, rkey, record); return { uri, cid, value: record }; }; export const deleteReaction = async (agent: OAuthUserAgent, rkey: string): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection: REACTION_COLLECTION, rkey: rkey as RecordKey } - }) - ); + await deleteRecord(agent, REACTION_COLLECTION, rkey); }; export interface ReactionListPage { diff --git a/web/src/lib/api/settings.ts b/web/src/lib/api/settings.ts index 02bdac371..570170ad6 100644 --- a/web/src/lib/api/settings.ts +++ b/web/src/lib/api/settings.ts @@ -1,13 +1,10 @@ -import { ok } from "@atcute/client"; import { now as tidNow } from "@atcute/tid"; -import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; -import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; import { jsonGet } from "./_request"; import type { BobbinContext, XrpcRequestInit } from "./client"; import { rkeyFromUri } from "./uri"; +import { createRecord, deleteRecord } from "./write"; import type * as ShTangledPublicKey from "./lexicons/types/sh/tangled/publicKey"; export type PublicKeyRecord = ShTangledPublicKey.Main; @@ -60,32 +57,22 @@ export const createPubKey = async ( name: string, key: string ): Promise => { - const rpc = createClient(agent); - // the client mints the rkey (a TID) rather than letting the PDS assign one. + // the rkey is a tid the client mints, rather than one the pds assigns const rkey = tidNow(); - await ok( - rpc.call(createRecordSchema, { - input: { - repo: agent.sub, - collection: PUBLIC_KEY_COLLECTION, - rkey: rkey as RecordKey, - record: { - $type: "sh.tangled.publicKey", - name, - key, - createdAt: new Date().toISOString() - } - } - }) + await createRecord( + agent, + PUBLIC_KEY_COLLECTION, + { + $type: "sh.tangled.publicKey", + name, + key, + createdAt: new Date().toISOString() + }, + rkey ); return rkey; }; export const deletePubKey = async (agent: OAuthUserAgent, rkey: string): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(deleteRecordSchema, { - input: { repo: agent.sub, collection: PUBLIC_KEY_COLLECTION, rkey: rkey as RecordKey } - }) - ); + await deleteRecord(agent, PUBLIC_KEY_COLLECTION, rkey); }; diff --git a/web/src/lib/api/strings.ts b/web/src/lib/api/strings.ts index 460c45eec..ba77596c1 100644 --- a/web/src/lib/api/strings.ts +++ b/web/src/lib/api/strings.ts @@ -1,9 +1,7 @@ -import { ok } from "@atcute/client"; -import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; -import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; -import { createClient } from "$lib/auth/agent"; import type { StringRecord } from "./records"; +import { putRecord } from "./write"; const STRING_COLLECTION = "sh.tangled.string" as Nsid; @@ -13,15 +11,5 @@ export const putString = async ( rkey: string, record: StringRecord ): Promise => { - const rpc = createClient(agent); - await ok( - rpc.call(putRecordSchema, { - input: { - repo: agent.sub, - collection: STRING_COLLECTION, - rkey: rkey as RecordKey, - record - } - }) - ); + await putRecord(agent, STRING_COLLECTION, rkey, record); }; diff --git a/web/src/lib/api/write.test.ts b/web/src/lib/api/write.test.ts new file mode 100644 index 000000000..39639dceb --- /dev/null +++ b/web/src/lib/api/write.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Nsid } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; + +const agent = { sub: "did:plc:writer" } as unknown as OAuthUserAgent; +const collection = "sh.tangled.repo.issue" as Nsid; +const record = { $type: "sh.tangled.repo.issue", title: "hi" }; + +// doMock needs the modules loaded after it, so the imports are dynamic +const load = async () => { + vi.resetModules(); + const inputs: Record[] = []; + vi.doMock("$app/environment", () => ({ browser: true })); + vi.doMock("$lib/auth/agent", () => ({ + createClient: () => ({ + call: async (_schema: unknown, options: { input: Record }) => { + inputs.push(options.input); + return { ok: true, data: { uri: "at://did:plc:writer/c/1", cid: "bafy" } }; + } + }) + })); + return { cache: await import("./cache"), write: await import("./write"), inputs }; +}; + +describe("record writes", () => { + it("drops cached reads so a write is visible on the next read", async () => { + const { cache, write } = await load(); + const read = vi.fn().mockResolvedValue("stale"); + + await cache.cached("issues:did:plc:repo:open:", 30_000, read); + await write.putRecord(agent, collection, "3lk", record); + await cache.cached("issues:did:plc:repo:open:", 30_000, read); + + expect(read).toHaveBeenCalledTimes(2); + }); + + it("forwards the swap guard so a concurrent edit loses", async () => { + const { write, inputs } = await load(); + + await write.putRecord(agent, collection, "3lk", record, "bafyread"); + + expect(inputs[0]).toMatchObject({ rkey: "3lk", swapRecord: "bafyread" }); + }); + + it("takes a minted rkey instead of letting the pds assign one", async () => { + const { write, inputs } = await load(); + + await write.createRecord(agent, collection, record, "3lkminted"); + + expect(inputs[0]).toMatchObject({ rkey: "3lkminted" }); + }); + + it("keeps the cache when the write fails", async () => { + vi.resetModules(); + vi.doMock("$app/environment", () => ({ browser: true })); + vi.doMock("$lib/auth/agent", () => ({ + createClient: () => ({ + call: async () => ({ + ok: false, + data: { error: "InvalidRequest", message: "nope" } + }) + }) + })); + const cache = await import("./cache"); + const write = await import("./write"); + const read = vi.fn().mockResolvedValue("fresh"); + + await cache.cached("issues:did:plc:repo:open:", 30_000, read); + await expect(write.deleteRecord(agent, collection, "3lk")).rejects.toThrow(); + await cache.cached("issues:did:plc:repo:open:", 30_000, read); + + expect(read).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/api/write.ts b/web/src/lib/api/write.ts new file mode 100644 index 000000000..372462117 --- /dev/null +++ b/web/src/lib/api/write.ts @@ -0,0 +1,71 @@ +import { ok } from "@atcute/client"; +import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; +import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; +import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; +import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { createClient } from "$lib/auth/agent"; +import { clear } from "./cache"; + +export interface WrittenRecord { + uri: string; + cid?: string; +} + +// every record write in the app goes through here, so the read cache is dropped +// on any mutation instead of each write site remembering which keys it dirties +export const createRecord = async ( + agent: OAuthUserAgent, + collection: Nsid, + record: T, + rkey?: string +): Promise => { + const { uri, cid } = await ok( + createClient(agent).call(createRecordSchema, { + input: { + repo: agent.sub, + collection, + ...(rkey ? { rkey: rkey as RecordKey } : {}), + record + } + }) + ); + clear(); + return { uri, cid }; +}; + +export const putRecord = async ( + agent: OAuthUserAgent, + collection: Nsid, + rkey: string, + record: T, + // the write fails if the record moved since it was read + swapRecord?: string +): Promise => { + const { uri, cid } = await ok( + createClient(agent).call(putRecordSchema, { + input: { + repo: agent.sub, + collection, + rkey: rkey as RecordKey, + ...(swapRecord ? { swapRecord } : {}), + record + } + }) + ); + clear(); + return { uri, cid }; +}; + +export const deleteRecord = async ( + agent: OAuthUserAgent, + collection: Nsid, + rkey: string +): Promise => { + await ok( + createClient(agent).call(deleteRecordSchema, { + input: { repo: agent.sub, collection, rkey: rkey as RecordKey } + }) + ); + clear(); +}; diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts index f1fd32048..63672b611 100644 --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -1,4 +1,5 @@ import type { Did } from "@atcute/lexicons/syntax"; +import { cached } from "$lib/api/cache"; import { createBobbinClient } from "$lib/api/client"; import { stream } from "$lib/api/load"; import { @@ -41,26 +42,32 @@ export const load: PageLoad = async (event) => { case "repos": { const q = event.url.searchParams.get("q")?.trim(); const repos = identity.then(({ did, handle }) => - fetchReposPage(ctx, { - did: did as Did, - handle, - viewerDid, - q: q || undefined - }).then((page) => page.items) + cached(`profile:${viewerDid}:${did}:repos:${q ?? ""}`, 30_000, () => + fetchReposPage(ctx, { + did: did as Did, + handle, + viewerDid, + q: q || undefined + }).then((page) => page.items) + ) ); return { tab: "repos" as const, repos: stream(repos) }; } case "strings": { const strings = identity.then(({ did, handle }) => - fetchStringsPage(ctx, { did: did as Did, handle }).then((page) => page.items) + cached(`profile:${viewerDid}:${did}:strings`, 30_000, () => + fetchStringsPage(ctx, { did: did as Did, handle }).then((page) => page.items) + ) ); return { tab: "strings" as const, strings: stream(strings) }; } case "followers": case "following": { const people = identity.then(({ did }) => - fetchPeoplePage(ctx, { did: did as Did, viewerDid, direction: tab }).then( - (page) => page.items + cached(`profile:${viewerDid}:${did}:${tab}`, 30_000, () => + fetchPeoplePage(ctx, { did: did as Did, viewerDid, direction: tab }).then( + (page) => page.items + ) ) ); return { tab, people: stream(people) }; @@ -74,7 +81,9 @@ export const load: PageLoad = async (event) => { } case "starred": { const stars = identity.then(({ did }) => - fetchStarredPage(ctx, { did: did as Did, viewerDid }).then((page) => page.items) + cached(`profile:${viewerDid}:${did}:starred`, 30_000, () => + fetchStarredPage(ctx, { did: did as Did, viewerDid }).then((page) => page.items) + ) ); return { tab: "starred" as const, stars: stream(stars) }; } diff --git a/web/src/routes/[handle]/[repo]/issues/+page.ts b/web/src/routes/[handle]/[repo]/issues/+page.ts index 4d08380bb..eacc1e895 100644 --- a/web/src/routes/[handle]/[repo]/issues/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/+page.ts @@ -1,3 +1,4 @@ +import { cached } from "$lib/api/cache"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; import { ISSUE_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -12,6 +13,8 @@ export const load: PageLoad = async (event) => { const parent = await event.parent(); const repo = Promise.resolve(parent.repo); const state = event.url.searchParams.get("state") === "closed" ? "closed" : "open"; + const cursor = event.url.searchParams.get("cursor"); + const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch @@ -21,24 +24,26 @@ export const load: PageLoad = async (event) => { const issues = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return [] as IssueSummary[]; - return enrich(ctx, { - xrpc: "sh.tangled.repo.listIssues", - params: { subject: repoDid, state }, - enrich: [target(ISSUE_AUTHOR_DOCS, ["items[].uri"])] - }).then((page) => - page.output.items.map((item): IssueSummary => { - const author = authorOf(page.data, item.uri, ISSUE_AUTHOR_DOCS); - return { - uri: item.uri, - rkey: rkeyFromUri(item.uri), - title: item.value.title, - state: item.state === "closed" ? "closed" : "open", - authorDid: author.did, - authorHandle: author.handle, - createdAt: item.value.createdAt, - commentCount: item.commentCount - }; - }) + return cached(`issues:${viewerDid}:${repoDid}:${state}:${cursor ?? ""}`, 30_000, () => + enrich(ctx, { + xrpc: "sh.tangled.repo.listIssues", + params: { subject: repoDid, state }, + enrich: [target(ISSUE_AUTHOR_DOCS, ["items[].uri"])] + }).then((page) => + page.output.items.map((item): IssueSummary => { + const author = authorOf(page.data, item.uri, ISSUE_AUTHOR_DOCS); + return { + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.value.title, + state: item.state === "closed" ? "closed" : "open", + authorDid: author.did, + authorHandle: author.handle, + createdAt: item.value.createdAt, + commentCount: item.commentCount + }; + }) + ) ); }); @@ -46,7 +51,9 @@ export const load: PageLoad = async (event) => { const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }) + return cached(`countIssues:${repoDid}:closed`, 30_000, () => + count(ctx, "sh.tangled.repo.countIssues", repoDid, { state: "closed" }) + ) .then((c) => c?.count ?? 0) .catch(() => 0); }); diff --git a/web/src/routes/[handle]/[repo]/pulls/+page.ts b/web/src/routes/[handle]/[repo]/pulls/+page.ts index 93a4726ec..0c14df60a 100644 --- a/web/src/routes/[handle]/[repo]/pulls/+page.ts +++ b/web/src/routes/[handle]/[repo]/pulls/+page.ts @@ -1,3 +1,4 @@ +import { cached } from "$lib/api/cache"; import { createBobbinClient } from "$lib/api/client"; import { count } from "$lib/api/count"; import { PULL_AUTHOR_DOCS } from "$lib/api/descriptors"; @@ -13,6 +14,8 @@ export const load: PageLoad = async (event) => { const repo = Promise.resolve(parent.repo); const raw = event.url.searchParams.get("state"); const state: PullState = raw === "closed" || raw === "merged" ? raw : "open"; + const cursor = event.url.searchParams.get("cursor"); + const viewerDid = parent.auth?.did; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch @@ -22,24 +25,29 @@ export const load: PageLoad = async (event) => { const pulls = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return [] as PullSummary[]; - return enrich(ctx, { - xrpc: "sh.tangled.repo.listPulls", - params: { subject: repoDid, status: state }, - enrich: [target(PULL_AUTHOR_DOCS, ["items[].uri"])] - }).then((page) => - page.output.items.map((item): PullSummary => { - const author = authorOf(page.data, item.uri, PULL_AUTHOR_DOCS); - return { - uri: item.uri, - rkey: rkeyFromUri(item.uri), - title: item.value.title, - state: item.state === "closed" || item.state === "merged" ? item.state : "open", - authorDid: author.did, - authorHandle: author.handle, - createdAt: item.value.createdAt, - commentCount: item.commentCount - }; - }) + return cached(`pulls:${viewerDid}:${repoDid}:${state}:${cursor ?? ""}`, 30_000, () => + enrich(ctx, { + xrpc: "sh.tangled.repo.listPulls", + params: { subject: repoDid, status: state }, + enrich: [target(PULL_AUTHOR_DOCS, ["items[].uri"])] + }).then((page) => + page.output.items.map((item): PullSummary => { + const author = authorOf(page.data, item.uri, PULL_AUTHOR_DOCS); + return { + uri: item.uri, + rkey: rkeyFromUri(item.uri), + title: item.value.title, + state: + item.state === "closed" || item.state === "merged" + ? item.state + : "open", + authorDid: author.did, + authorHandle: author.handle, + createdAt: item.value.createdAt, + commentCount: item.commentCount + }; + }) + ) ); }); @@ -47,7 +55,9 @@ export const load: PageLoad = async (event) => { const closedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }) + return cached(`countPulls:${repoDid}:closed`, 30_000, () => + count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "closed" }) + ) .then((c) => c?.count ?? 0) .catch(() => 0); }); @@ -55,7 +65,9 @@ export const load: PageLoad = async (event) => { const mergedCount = repo.then(async (r) => { const repoDid = r.repoDid; if (!repoDid) return 0; - return count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "merged" }) + return cached(`countPulls:${repoDid}:merged`, 30_000, () => + count(ctx, "sh.tangled.repo.countPulls", repoDid, { status: "merged" }) + ) .then((c) => c?.count ?? 0) .catch(() => 0); }); -- 2.51.2