Repository navigation
Pets: a new name or photo reaches every copy of it #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<unknown>; | ||
| 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<string, unknown>) { | ||
| 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")); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void> { | ||
| 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 } | ||
|
Comment on lines
+794
to
+795
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 馃憤聽/ 馃憥. |
||
| ), | ||
| ]); | ||
| 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<string, string> | ||
| ): Promise<void> { | ||
| 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<string, string> | ||
| ): Promise<void> { | ||
| 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<string, string> | ||
| ): Promise<void> { | ||
| 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; | ||
| }) | ||
| ) | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exporting this trigger does not make it run in the repository's local acceptance environment:
functions/scripts/callable-shim.mjs:278-298manually enumerates every Firestore trigger and has noonPetUpdatedentry. Consequently, editing a pet through the shim leaves posts, follows, rosters, and check-ins stale even though the same flow works after deployment, so local client-wiring acceptance cannot exercise the behavior introduced here. Add the pets collection update watcher alongside the export.Useful? React with 馃憤聽/ 馃憥.