From 3ba5444b56990b6ee3d9519b1f82bc007c6a0c68 Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Wed, 14 Oct 2026 16:16:41 -0700 Subject: [PATCH 1/2] Pets: a new name or photo reaches every copy of it Posts, follow lists, meetup rosters and check-ins each keep the pet's name, and all but check-ins its photo, as they were when written, and the apps show those copies. A pet renamed or given a new photo went on showing the old ones everywhere but its own page. People's names have had onUserUpdated for this; pets had nothing. onPetUpdated runs when a pet's name or photo changes and writes the pet's current ones onto every copy, then looks at the pet again and goes round once more if it changed in the meantime, so two changes in quick succession end with the latest. A copy already up to date is not written, and one that went while the copies were being written (a follow undone) is left out rather than written back. Roster entries are found through the pet's owners, on the index that exists. Co-Authored-By: Claude Opus 5.5 (1M context) --- functions/src/__tests__/pet-name-sync.test.ts | 137 +++++++++++++++++ functions/src/index.ts | 1 + functions/src/pets.ts | 140 +++++++++++++++++- 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 functions/src/__tests__/pet-name-sync.test.ts diff --git a/functions/src/__tests__/pet-name-sync.test.ts b/functions/src/__tests__/pet-name-sync.test.ts new file mode 100644 index 00000000..c9e5fe7c --- /dev/null +++ b/functions/src/__tests__/pet-name-sync.test.ts @@ -0,0 +1,137 @@ +import "./setup"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { db } from "../platform"; +import { onPetUpdated } from "../pets"; +import { captureSnapshot, deliverWritten, newEventId, purge } from "./helpers"; + +// A pet's name and photo are copied onto its posts, its followers' follow +// lists, meetup rosters and check-ins, and the apps show those copies. A pet +// renamed or given a new photo went on showing the old ones everywhere but its +// own page. onPetUpdated now writes the pet's current name and photo onto +// every copy. + +const PET_ID = "sync-pet"; +const PET = `pets/${PET_ID}`; +const OWNER = "sync-owner"; +const FOLLOWER = "sync-follower"; +const POST = "posts/sync-post"; +const OTHER_POST = "posts/sync-other-post"; +const FOLLOW = `users/${FOLLOWER}/followingPets/${PET_ID}`; +const ENTRY = `meetups/sync-meetup/participants/${OWNER}`; +const CHECKIN = `locations/sync-place/checkins/${OWNER}_2026-10-14`; +const OLD_PHOTO = "https://res.cloudinary.com/demo/image/upload/old.jpg"; +const NEW_PHOTO = "https://res.cloudinary.com/demo/image/upload/new.jpg"; + +type Method = (...args: unknown[]) => Promise; +const refProto = () => Object.getPrototypeOf(db.doc(PET)) as Record<"update", Method>; + +async function wipe() { + await purge(PET, POST, OTHER_POST, `users/${FOLLOWER}`, "meetups/sync-meetup", "locations/sync-place"); +} + +async function seed() { + await db.doc(PET).set({ name: "Mochi", avatarUrl: OLD_PHOTO, ownerId: OWNER, primaryOwnerId: OWNER, followerCount: 1, postCount: 1 }); + await db.doc(`${PET}/family/${OWNER}`).set({ userId: OWNER, role: "primary" }); + await db.doc(POST).set({ authorId: OWNER, petId: PET_ID, petName: "Mochi", petAvatarUrl: OLD_PHOTO, text: "a post" }); + await db.doc(OTHER_POST).set({ authorId: OWNER, petId: "another-pet", petName: "Other", petAvatarUrl: OLD_PHOTO, text: "another" }); + await db.doc(FOLLOW).set({ petId: PET_ID, petName: "Mochi", petAvatar: OLD_PHOTO, counted: true }); + await db.doc(ENTRY).set({ meetupId: "sync-meetup", userId: OWNER, petId: PET_ID, petName: "Mochi", petAvatar: OLD_PHOTO }); + await db.doc(CHECKIN).set({ userId: OWNER, petId: PET_ID, petName: "Mochi" }); +} + +/** Changes the pet, then delivers that change's event the way Firestore would. */ +async function change(fields: Record) { + const before = await captureSnapshot(PET); + await db.doc(PET).update(fields); + const after = await captureSnapshot(PET); + await deliverWritten(onPetUpdated, before, after, { petId: PET_ID }, newEventId("pet")); +} + +async function copies() { + const [post, follow, entry, checkin, other] = await Promise.all( + [POST, FOLLOW, ENTRY, CHECKIN, OTHER_POST].map(async (path) => (await db.doc(path).get()).data()) + ); + return { post, follow, entry, checkin, other }; +} + +beforeEach(async () => { + await wipe(); + await seed(); +}); +afterEach(() => vi.restoreAllMocks()); +afterAll(wipe); + +describe("a pet's name and photo, wherever they are copied", () => { + it("are the new ones on its posts, follow lists, meetup rosters and check-ins", async () => { + await change({ name: "Mochi Jr", avatarUrl: NEW_PHOTO }); + + const c = await copies(); + expect([c.post?.petName, c.post?.petAvatarUrl]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect([c.follow?.petName, c.follow?.petAvatar]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect([c.entry?.petName, c.entry?.petAvatar]).toEqual(["Mochi Jr", NEW_PHOTO]); + expect(c.checkin?.petName).toBe("Mochi Jr"); + expect([c.other?.petName, c.other?.petAvatarUrl], "another pet's post changed").toEqual(["Other", OLD_PHOTO]); + }); + + it("are not written when only the pet's counts change", async () => { + const before = await captureSnapshot(PET); + await db.doc(PET).update({ followerCount: 2 }); + const after = await captureSnapshot(PET); + const update = vi.spyOn(refProto(), "update"); + + await deliverWritten(onPetUpdated, before, after, { petId: PET_ID }, newEventId("pet")); + + expect(update).not.toHaveBeenCalled(); + }); + + it("end with the latest name when an earlier change is copied last", async () => { + const first = await captureSnapshot(PET); + await db.doc(PET).update({ name: "Mochi Jr" }); + const second = await captureSnapshot(PET); + await db.doc(PET).update({ name: "Mochi the Third" }); + const third = await captureSnapshot(PET); + + await deliverWritten(onPetUpdated, second, third, { petId: PET_ID }, newEventId("pet")); + await deliverWritten(onPetUpdated, first, second, { petId: PET_ID }, newEventId("pet")); + + const c = await copies(); + expect([c.post?.petName, c.follow?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(4).fill("Mochi the Third")); + }); + + it("end with the latest name when the pet is renamed again while they are written", async () => { + // As soon as the first copy is written, the pet is renamed once more, and + // that rename's own event is never delivered here: the sync has to notice. + const proto = refProto(); + const realUpdate = proto.update; + let renamed = false; + vi.spyOn(proto, "update").mockImplementation(async function (this: { path: string }, ...args: unknown[]) { + if (!renamed && this.path !== PET) { + renamed = true; + await realUpdate.call(db.doc(PET), { name: "Mochi the Third" }); + } + return realUpdate.apply(this, args); + }); + + await change({ name: "Mochi Jr" }); + + expect(renamed).toBe(true); + const c = await copies(); + expect([c.post?.petName, c.follow?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(4).fill("Mochi the Third")); + }); + + it("leave out a follow undone while they are written, and write the rest", async () => { + // The follower unfollows just as their copy is about to be written. + const proto = refProto(); + const realUpdate = proto.update; + vi.spyOn(proto, "update").mockImplementation(async function (this: { path: string }, ...args: unknown[]) { + if (this.path === FOLLOW) await db.doc(FOLLOW).delete(); + return realUpdate.apply(this, args); + }); + + await change({ name: "Mochi Jr" }); + + expect((await db.doc(FOLLOW).get()).exists, "the undone follow was written back").toBe(false); + const c = await copies(); + expect([c.post?.petName, c.entry?.petName, c.checkin?.petName]).toEqual(Array(3).fill("Mochi Jr")); + }); +}); diff --git a/functions/src/index.ts b/functions/src/index.ts index b20b4c59..0d48ca3a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -70,6 +70,7 @@ export { createPetCallable, deletePetCallable, followPetCallable, + onPetUpdated, resumeAbandonedPetDeletions, unfollowPetCallable, updatePetCallable, diff --git a/functions/src/pets.ts b/functions/src/pets.ts index 95147bd8..305e37cb 100644 --- a/functions/src/pets.ts +++ b/functions/src/pets.ts @@ -1,10 +1,12 @@ +import { onDocumentUpdated } from "firebase-functions/v2/firestore"; import { onCall, HttpsError } from "firebase-functions/v2/https"; import { onSchedule } from "firebase-functions/v2/scheduler"; -import { admin, db, FieldValue, Timestamp } from "./platform"; +import { admin, db, FieldPath, FieldValue, Timestamp } from "./platform"; import { cascadeDeletePet } from "./cleanup"; import { assertCallerAccountActive, getNotificationActor } from "./notifications"; import { assertRateLimit, + FIRESTORE_BATCH_LIMIT, getDefaultAvatar, optionalTrimmedString, optionalTrustedHttpsUrl, @@ -743,3 +745,139 @@ export const unfollowPetCallable = onCall(async (request) => { } return { success: true }; }); + +/** + * Keeps the copies of a pet's name and photo in step with the pet. + * + * Posts, follow lists, meetup rosters and check-ins each keep the pet's name, + * and all but check-ins its photo, as they were when written, and the apps + * show those copies. Without this, a pet renamed or given a new photo went on + * showing the old ones everywhere but its own page. + */ +export const onPetUpdated = onDocumentUpdated( + { document: "pets/{petId}", timeoutSeconds: 540, memory: "512MiB" }, + async (event) => { + const before = event.data?.before.data(); + const after = event.data?.after.data(); + if (!before || !after) return; + // Most writes to a pet are its counts. Only a new name or photo has + // anything to copy. + if (before.name === after.name && before.avatarUrl === after.avatarUrl) return; + await syncPetCopies(event.params.petId); + } +); + +/** + * Writes the pet's current name and photo onto every copy, then looks at the + * pet again. Two changes in quick succession can be copied at the same time, + * or the earlier one last; whichever finishes finds what it wrote out of date + * and goes round again. The values always come from the pet as it is now, + * never from the event. + */ +export async function syncPetCopies(petId: string): Promise { + const petRef = db.doc(`pets/${petId}`); + let pet = await petRef.get(); + for (let pass = 0; pass < 5 && pet.exists; pass += 1) { + const data = pet.data() ?? {}; + const { name, avatar } = petIdentity(petId, data); + await Promise.all([ + updateCopies( + db.collection("posts").where("petId", "==", petId), + { petName: name, petAvatarUrl: avatar } + ), + updateCopies( + db.collectionGroup("followingPets").where("petId", "==", petId), + { petName: name, petAvatar: avatar } + ), + updateRosterCopies(petId, data, { petName: name, petAvatar: avatar }), + updateCopies( + db.collectionGroup("checkins").where("petId", "==", petId), + { petName: name } + ), + ]); + pet = await petRef.get(); + const now = petIdentity(petId, pet.data() ?? {}); + if (now.name === name && now.avatar === avatar) return; + } +} + +/** The name and photo a copy carries, the way the callables write them. */ +function petIdentity( + petId: string, + pet: admin.firestore.DocumentData +): { name: string; avatar: string } { + return { + name: typeof pet.name === "string" && pet.name.trim().length > 0 ? pet.name : "Pet", + avatar: + typeof pet.avatarUrl === "string" && pet.avatarUrl.trim().length > 0 + ? pet.avatarUrl + : getDefaultAvatar(petId), + }; +} + +/** Every document the query finds, a page at a time. */ +async function updateCopies( + query: admin.firestore.Query, + fields: Record +): Promise { + const ordered = query.orderBy(FieldPath.documentId()); + let last: admin.firestore.QueryDocumentSnapshot | null = null; + for (;;) { + const page: admin.firestore.QuerySnapshot = await (last ? ordered.startAfter(last) : ordered) + .limit(FIRESTORE_BATCH_LIMIT) + .get(); + if (page.empty) return; + await updateEach(page.docs, fields); + last = page.docs[page.docs.length - 1]; + if (page.size < FIRESTORE_BATCH_LIMIT) return; + } +} + +/** + * Meetup roster entries name the person and the pet they came with, and the + * index there is on the person, so the pet's entries are found through its + * owners. An entry by someone who has since left the pet's family keeps the + * name it was written with. + */ +async function updateRosterCopies( + petId: string, + pet: admin.firestore.DocumentData, + fields: Record +): Promise { + const family = await db.collection(`pets/${petId}/family`).limit(PET_FAMILY_READ_LIMIT).get(); + const owners = new Set(family.docs.map((docSnap) => docSnap.id)); + for (const field of ["ownerId", "primaryOwnerId"]) { + if (typeof pet[field] === "string" && pet[field]) owners.add(pet[field]); + } + const uids = Array.from(owners); + for (let i = 0; i < uids.length; i += 30) { + const entries = await db + .collectionGroup("participants") + .where("userId", "in", uids.slice(i, i + 30)) + .get(); + await updateEach(entries.docs.filter((docSnap) => docSnap.data().petId === petId), fields); + } +} + +/** + * A hundred at a time, skipping a copy that already says it, and leaving out + * one that went while the copies were being written: a follow undone, a + * roster entry left. update() so a copy that is gone stays gone. + */ +async function updateEach( + docs: admin.firestore.QueryDocumentSnapshot[], + fields: Record +): Promise { + const stale = docs.filter((docSnap) => + Object.entries(fields).some(([key, value]) => docSnap.get(key) !== value) + ); + for (let i = 0; i < stale.length; i += 100) { + await Promise.all( + stale.slice(i, i + 100).map((docSnap) => + docSnap.ref.update(fields).catch((error: unknown) => { + if ((error as { code?: unknown })?.code !== 5) throw error; + }) + ) + ); + } +} From ffc651afbb83607dbc26cff95726cefdc54df31f Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Wed, 14 Oct 2026 16:16:41 -0700 Subject: [PATCH 2/2] Test project: the pet name and photo sync Authorized by the owner on 2026-10-14 with the change that adds it, so that it can be tested with many people on the test project. It binds no secret, calls nothing outside and runs on no schedule; Firestore only. The entry now expects 42 functions. Co-Authored-By: Claude Opus 5.5 (1M context) --- functions-testcloud/index.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/functions-testcloud/index.js b/functions-testcloud/index.js index f66e55e7..8e472936 100644 --- a/functions-testcloud/index.js +++ b/functions-testcloud/index.js @@ -7,7 +7,8 @@ * effects outside Firestore (Firebase Auth updateUser in the two profile * callables); 5 more on 2026-10-01 for places and meetups, audited the same * way (no secrets, no network calls, no schedules, Firestore only); 2 more on - * 2026-10-14 for leaving and cancelling a meetup, audited the same way. Nothing + * 2026-10-14 for leaving and cancelling a meetup, and 1 that day for a pet's + * new name or photo reaching its copies, audited the same way. Nothing * that binds a secret, runs on a schedule, or deletes media belongs here; the * guards below refuse the first two. * @@ -125,6 +126,12 @@ const EXPORTS = { // schedule; both only read and write Firestore. onParticipantDeleted: meetups.onParticipantDeleted, onMeetupUpdated: notifications.onMeetupUpdated, + + // Authorized by the owner on 2026-10-14 with the change that adds it: a + // pet's new name or photo written onto its posts, follow lists, meetup + // rosters and check-ins. No secret, no call out, no schedule; Firestore + // only. + onPetUpdated: pets.onPetUpdated, }; // The test project never resolves to production's Cloudinary account. With @@ -150,7 +157,7 @@ if (testAccount) { // Exactly the authorized set: a name added here by accident is a deployment // nobody approved. -const EXPECTED_COUNT = 41 + (testAccount ? MEDIA_FUNCTIONS.length : 0); +const EXPECTED_COUNT = 42 + (testAccount ? MEDIA_FUNCTIONS.length : 0); if (Object.keys(EXPORTS).length !== EXPECTED_COUNT) { throw new Error(`testcloud entry: ${Object.keys(EXPORTS).length} exports, ${EXPECTED_COUNT} authorized`); }