diff --git a/deploy/litestream.yml b/deploy/litestream.yml index 402a143..93b3b5c 100644 --- a/deploy/litestream.yml +++ b/deploy/litestream.yml @@ -16,3 +16,22 @@ dbs: sync-interval: 60s retention: 720h snapshot-interval: 24h + + # Contributor registry: a complementary seed list for rebuilding from PDSes. + - path: /var/lib/lichen/registry.db + replicas: + - type: file + path: /var/lib/lichen/backups-registry + sync-interval: 10s + retention: 2160h + snapshot-interval: 24h + + - type: s3 + bucket: lichen-backups + path: registry + endpoint: https://${LITESTREAM_R2_ACCOUNT_ID}.r2.cloudflarestorage.com + access-key-id: ${LITESTREAM_R2_ACCESS_KEY_ID} + secret-access-key: ${LITESTREAM_R2_SECRET_ACCESS_KEY} + sync-interval: 60s + retention: 720h + snapshot-interval: 24h diff --git a/src/lib/orchestrators/bookmark.ts b/src/lib/orchestrators/bookmark.ts index 67f6e52..a462fa9 100644 --- a/src/lib/orchestrators/bookmark.ts +++ b/src/lib/orchestrators/bookmark.ts @@ -15,6 +15,9 @@ export async function addBookmarkAction( wikiAtUri: string, session: Session | null, ): Promise { + // Idempotent: never mint a second record for an already-bookmarked wiki. + if (getBookmarkAtUri(did, wikiAtUri)) return; + const now = new Date().toISOString(); const tid = TID.now(); const atUri = `at://${did}/${COLLECTIONS.bookmark}/${tid}`; diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index ce3e205..4b56ffc 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -10,6 +10,7 @@ import { deleteRequest, getMembership, getMembershipAtUri, + getRequest, upsertMembership, upsertRequest, } from "../../server/db/queries/index.ts"; @@ -28,6 +29,10 @@ export async function requestAccessAction( } const session = ctx.session; + + // Idempotent: never mint a second record for an already-pending request. + if (getRequest(ctx.wiki.at_uri, session.did)) return; + const now = new Date().toISOString(); const tid = TID.now(); const atUri = `at://${session.did}/wiki.lichen.memberRequest/${tid}`; diff --git a/tests/lib/orchestrators/bookmark.test.ts b/tests/lib/orchestrators/bookmark.test.ts index 97d0f41..751001d 100644 --- a/tests/lib/orchestrators/bookmark.test.ts +++ b/tests/lib/orchestrators/bookmark.test.ts @@ -73,6 +73,18 @@ describe("addBookmarkAction", () => { ).rejects.toBeInstanceOf(PdsWriteError); expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(false); }); + + test("re-adding an already-bookmarked wiki writes no second PDS record", async () => { + const db = getDb(); + db.run("DELETE FROM bookmarks WHERE did = ?", [USER_DID]); + mockWriteBookmarkRecord.mockClear(); + + await addBookmarkAction(USER_DID, WIKI_AT_URI, session); + await addBookmarkAction(USER_DID, WIKI_AT_URI, session); + + expect(mockWriteBookmarkRecord).toHaveBeenCalledTimes(1); + expect(isBookmarked(USER_DID, WIKI_AT_URI)).toBe(true); + }); }); describe("deleteBookmarkAction", () => { diff --git a/tests/lib/orchestrators/membership.test.ts b/tests/lib/orchestrators/membership.test.ts index e47aa58..e3d50df 100644 --- a/tests/lib/orchestrators/membership.test.ts +++ b/tests/lib/orchestrators/membership.test.ts @@ -148,6 +148,21 @@ describe("requestAccessAction", () => { PdsWriteError, ); }); + + test("re-requesting access writes no second PDS record", async () => { + mockWriteMemberRequestRecord.mockClear(); + + const ctx = makeCtx({ + session: { did: "did:plc:dup-requester", handle: "dup.bsky.social" }, + did: "did:plc:dup-requester", + access: "none", + }); + + await requestAccessAction(ctx); + await requestAccessAction(ctx); + + expect(mockWriteMemberRequestRecord).toHaveBeenCalledTimes(1); + }); }); describe("approveMemberAction", () => {