Skip to content

Commit a926f13

Browse files
committed
add a function and script for cleaning old roots
1 parent 55a4684 commit a926f13

10 files changed

+190
-9
lines changed

.vscode/launch.json

+9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
"console": "integratedTerminal",
1515
"program": "${workspaceFolder}/node_modules/.bin/jest",
1616
"args": ["${fileBasenameNoExtension}"]
17+
},
18+
{
19+
"name": "Debug functions-v2 test",
20+
"request": "launch",
21+
"type": "node",
22+
"console": "integratedTerminal",
23+
"program": "${workspaceFolder}/functions-v2/node_modules/.bin/jest",
24+
"args": ["${fileBasenameNoExtension}"],
25+
"cwd": "${workspaceFolder}/functions-v2"
1726
}
1827
]
1928
}

functions-v2/jest.config.js

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ module.exports = {
22
preset: 'ts-jest',
33
testEnvironment: 'node',
44
testPathIgnorePatterns: ['lib/', 'node_modules/'],
5+
moduleNameMapper: {
6+
// These are necessary so code imported from ../shared/ will use the same version of
7+
// firebase-admin that the local code does.
8+
// The explicit `^` and `$` are needed so this only matches what we are importing.
9+
// Otherwise it breaks the internal firebase admin code's imports
10+
"^firebase-admin$": "<rootDir>/node_modules/firebase-admin",
11+
"^firebase-admin/firestore$": "<rootDir>/node_modules/firebase-admin/lib/firestore",
12+
"^firebase-admin/app$": "<rootDir>/node_modules/firebase-admin/lib/app",
13+
}
514
};
615

716
// This is configured here because the clearFirebaseData function from

functions-v2/package-lock.json

+7-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

functions-v2/package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
"build": "tsc",
66
"build:watch": "tsc --watch",
77
"emulator": "firebase emulators:start --project demo-test",
8+
"emulator:online": "firebase emulators:start",
89
"serve": "npm run build && firebase emulators:start --only functions",
9-
"shell": "npm run build && firebase functions:shell",
10+
"shell": "npm run build && firebase functions:shell --project demo-test",
1011
"start": "npm run shell",
1112
"test": "jest",
1213
"test:emulator": "firebase emulators:start --project demo-test --only firestore,database",
@@ -16,10 +17,10 @@
1617
"engines": {
1718
"node": "20"
1819
},
19-
"main": "lib/src/index.js",
20+
"main": "lib/functions-v2/src/index.js",
2021
"dependencies": {
2122
"firebase-admin": "^12.1.0",
22-
"firebase-functions": "^5.0.0"
23+
"firebase-functions": "^5.1.1"
2324
},
2425
"devDependencies": {
2526
"@jest/globals": "^29.7.0",

functions-v2/src/at-midnight.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {onSchedule} from "firebase-functions/v2/scheduler";
2+
import * as logger from "firebase-functions/logger";
3+
4+
// NOTE: in order for this import from shared to work it is necessary
5+
// to alias "firebase-admin" in tsconfig.json. Otherwise Typescript will
6+
// read the types from the parent node_modules. The parent directory
7+
// has a different version of the firebase dependencies, which cause
8+
// type errors.
9+
import {cleanFirebaseRoots} from "../../shared/clean-firebase-roots";
10+
11+
export const atMidnight = onSchedule("0 0 * * *", runAtMidnight);
12+
13+
// This function is split out so it can be tested by Jest. The
14+
// firebase-functions-test library doesn't support wrapping onSchedule.
15+
export async function runAtMidnight() {
16+
await cleanFirebaseRoots({
17+
appMode: "qa",
18+
// hoursAgo: 6,
19+
hoursAgo: 90.7,
20+
logger,
21+
dryRun: true,
22+
});
23+
24+
// When cleanFirebaseRoots is called from a NodeJS script it is
25+
// necessary to call Firebase's deleteApp so no threads are left running.
26+
// Inside of a firebase function according to
27+
// https://stackoverflow.com/a/72933644/3195497
28+
// it isn't necessary to call deleteApp when the function is done.
29+
}

functions-v2/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as admin from "firebase-admin";
22
export {onUserDocWritten} from "./on-user-doc-written";
3+
export {atMidnight} from "./at-midnight";
34

45
admin.initializeApp();

functions-v2/test/at-midnight.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import initializeFFT from "firebase-functions-test";
2+
import {
3+
clearFirestoreData,
4+
} from "firebase-functions-test/lib/providers/firestore";
5+
import {initializeApp} from "firebase-admin/app";
6+
// import {getFirestore} from "firebase-admin/firestore";
7+
import {runAtMidnight} from "../src/at-midnight";
8+
9+
process.env["FIRESTORE_EMULATOR_HOST"]="127.0.0.1:8088";
10+
const projectConfig = {projectId: "demo-test"};
11+
const fft = initializeFFT(projectConfig);
12+
13+
// When the function is running in the cloud initializeApp is called by index.ts
14+
// Here we are importing the function's module directly so we can call
15+
// initializeApp ourselves. This is beneficial since initializeApp needs to
16+
// be called after initializeFFT above.
17+
initializeApp();
18+
19+
// type CollectionRef = admin.firestore.CollectionReference<
20+
// admin.firestore.DocumentData, admin.firestore.DocumentData
21+
// >;
22+
23+
describe("atMidnight", () => {
24+
beforeEach(async () => {
25+
await clearFirestoreData(projectConfig);
26+
});
27+
28+
test("clean up firestore roots", async () => {
29+
// The wrapper doesn't support onSchedule. The Typescript types don't allow it
30+
// and at run time it doesn't pass the right event:
31+
// https://github.com/firebase/firebase-functions-test/issues/210
32+
// const wrapped = fft.wrap(atMidnight);
33+
await runAtMidnight();
34+
});
35+
36+
afterAll(() => {
37+
fft.cleanup();
38+
});
39+
});

functions-v2/tsconfig.json

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
// This prevents typescript from trying to include @types from the parent folders.
1212
// The types in the parent folders conflict so they break the build.
1313
"typeRoots": ["./node_modules/@types"],
14+
"paths": {
15+
// These are necessary so code imported from ../shared/ will use the same version of
16+
// firebase-admin that the local code does.
17+
"firebase-admin": ["./node_modules/firebase-admin/lib"],
18+
"firebase-admin/firestore": ["./node_modules/firebase-admin/lib/firestore"],
19+
"firebase-admin/app": ["./node_modules/firebase-admin/lib/app"],
20+
},
21+
1422
},
1523
"compileOnSave": true,
1624
"include": [

scripts/clean-firebase-roots.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/node
2+
3+
// This script finds documents without metadata in the realtime database.
4+
// If the deleteTypes array is uncommented, it will delete these documents.
5+
6+
// to run this script type the following in the terminal
7+
// cf. https://stackoverflow.com/a/66626333/16328462
8+
// $ cd scripts/ai
9+
// $ npx tsx clean-firebase-roots.ts
10+
11+
import admin from "firebase-admin";
12+
import { deleteApp } from "firebase-admin/app";
13+
import {cleanFirebaseRoots} from "../shared/clean-firebase-roots.js";
14+
import { getScriptRootFilePath } from "./lib/script-utils.js";
15+
16+
const databaseURL = "https://collaborative-learning-ec215.firebaseio.com";
17+
18+
const serviceAccountFile = getScriptRootFilePath("serviceAccountKey.json");
19+
const credential = admin.credential.cert(serviceAccountFile);
20+
// Initialize the app with a service account, granting admin privileges
21+
const fbApp = admin.initializeApp({
22+
credential,
23+
databaseURL
24+
});
25+
26+
await cleanFirebaseRoots({
27+
appMode: "qa",
28+
hoursAgo: 90.7,
29+
logger: console,
30+
dryRun: true
31+
});
32+
33+
await deleteApp(fbApp);

shared/clean-firebase-roots.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// This requires the modern firebase-admin, so it can't be used by functions-v1
2+
import {Timestamp, getFirestore} from "firebase-admin/firestore";
3+
import {getDatabase} from "firebase-admin/database";
4+
5+
const HOUR = 1000 * 60 * 60;
6+
7+
interface Logger {
8+
info(...args: any[]): void;
9+
}
10+
11+
interface Params {
12+
appMode: "qa" | "dev";
13+
hoursAgo: number;
14+
logger: Logger;
15+
dryRun?: boolean;
16+
}
17+
18+
export async function cleanFirebaseRoots(
19+
{ appMode, hoursAgo, logger, dryRun }: Params
20+
) {
21+
22+
// Be extra careful so we don't delete production data
23+
if (!["qa", "dev"].includes(appMode)) {
24+
throw new Error(`Invalid appMode ${appMode}`);
25+
}
26+
27+
// Clean up Firestore and Realtime database roots that haven't been updated in a 6 hours
28+
const cutOffMillis = Date.now() - hoursAgo*HOUR;
29+
const qaRootsResult = await getFirestore()
30+
.collection(appMode)
31+
.where("lastLaunchTime", "<", Timestamp.fromMillis(cutOffMillis))
32+
.get();
33+
34+
logger.info(`Found ${qaRootsResult.size} roots to delete`);
35+
36+
// Need to be careful to clean up the root in the realtime database
37+
// first. The record in Firestore is our only way to figure out which
38+
// roots in the realtime database need to be deleted.
39+
for (const root of qaRootsResult.docs) {
40+
// The Realtime database root is deleted first incase it fails.
41+
// This way the root in firestore will remain so we can find it
42+
// and try again later.
43+
const databasePath = `/${appMode}/${root.id}`;
44+
logger.info(`Deleting Realtime Database root: ${databasePath} ...`);
45+
// TODO: what if the ref doesn't exist?
46+
if (!dryRun) await getDatabase().ref(`/${appMode}/${root.id}`).remove();
47+
logger.info(`Deleting Firestore root: ${root.ref.path} ...`);
48+
if (!dryRun) await getFirestore().recursiveDelete(root.ref);
49+
}
50+
51+
}

0 commit comments

Comments
 (0)