@firebase-function-kits/delete-user-data 0.0.1 → 0.0.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +220 -0
  3. package/lib/config.d.ts +22 -0
  4. package/lib/config.d.ts.map +1 -0
  5. package/lib/config.js +153 -0
  6. package/lib/config.js.map +1 -0
  7. package/lib/events.d.ts +18 -0
  8. package/lib/events.d.ts.map +1 -0
  9. package/lib/events.js +71 -0
  10. package/lib/events.js.map +1 -0
  11. package/lib/export-config.d.ts +55 -0
  12. package/lib/export-config.d.ts.map +1 -0
  13. package/lib/export-config.js +56 -0
  14. package/lib/export-config.js.map +1 -0
  15. package/lib/handlers.d.ts +35 -0
  16. package/lib/handlers.d.ts.map +1 -0
  17. package/lib/handlers.js +243 -0
  18. package/lib/handlers.js.map +1 -0
  19. package/lib/helpers.d.ts +19 -0
  20. package/lib/helpers.d.ts.map +1 -0
  21. package/lib/helpers.js +39 -0
  22. package/lib/helpers.js.map +1 -0
  23. package/lib/index.d.ts +6 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +112 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/lib.d.ts +23 -0
  28. package/lib/lib.d.ts.map +1 -0
  29. package/lib/lib.js +38 -0
  30. package/lib/lib.js.map +1 -0
  31. package/lib/logs.d.ts +26 -0
  32. package/lib/logs.d.ts.map +1 -0
  33. package/lib/logs.js +116 -0
  34. package/lib/logs.js.map +1 -0
  35. package/lib/recursiveDelete.d.ts +18 -0
  36. package/lib/recursiveDelete.d.ts.map +1 -0
  37. package/lib/recursiveDelete.js +37 -0
  38. package/lib/recursiveDelete.js.map +1 -0
  39. package/lib/runBatchPubSubDeletions.d.ts +27 -0
  40. package/lib/runBatchPubSubDeletions.d.ts.map +1 -0
  41. package/lib/runBatchPubSubDeletions.js +47 -0
  42. package/lib/runBatchPubSubDeletions.js.map +1 -0
  43. package/lib/runCustomSearchFunction.d.ts +18 -0
  44. package/lib/runCustomSearchFunction.d.ts.map +1 -0
  45. package/lib/runCustomSearchFunction.js +78 -0
  46. package/lib/runCustomSearchFunction.js.map +1 -0
  47. package/lib/search.d.ts +19 -0
  48. package/lib/search.d.ts.map +1 -0
  49. package/lib/search.js +29 -0
  50. package/lib/search.js.map +1 -0
  51. package/npm-shrinkwrap.json +4167 -0
  52. package/package.json +37 -4
  53. package/src/config.ts +188 -0
  54. package/src/events.ts +38 -0
  55. package/src/export-config.ts +112 -0
  56. package/src/handlers.ts +271 -0
  57. package/src/helpers.ts +42 -0
  58. package/src/index.ts +100 -0
  59. package/src/lib.ts +41 -0
  60. package/src/logs.ts +127 -0
  61. package/src/recursiveDelete.ts +42 -0
  62. package/src/runBatchPubSubDeletions.ts +66 -0
  63. package/src/runCustomSearchFunction.ts +50 -0
  64. package/src/search.ts +37 -0
  65. package/tests/config.test.ts +180 -0
  66. package/tests/export-config.test.ts +117 -0
  67. package/tests/fakes.ts +325 -0
  68. package/tests/handlers.test.ts +517 -0
  69. package/tests/helpers.test.ts +126 -0
  70. package/tests/lib.test.ts +40 -0
  71. package/tests/recursiveDelete.test.ts +102 -0
  72. package/tests/runBatchPubSubDeletions.test.ts +164 -0
  73. package/tests/runCustomSearchFunction.test.ts +125 -0
  74. package/tests/search.test.ts +127 -0
  75. package/tsconfig.json +18 -0
  76. package/tsconfig.tsbuildinfo +1 -0
  77. package/index.js +0 -0
package/package.json CHANGED
@@ -1,12 +1,45 @@
1
1
  {
2
2
  "name": "@firebase-function-kits/delete-user-data",
3
- "version": "0.0.1",
3
+ "version": "0.0.2-rc.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/firebase/extensions.git",
7
+ "directory": "kits/delete-user-data"
8
+ },
4
9
  "description": "Delete user data across Firestore, RTDB, and Storage on account deletion",
5
10
  "license": "Apache-2.0",
6
- "main": "index.js",
7
- "private": false,
8
- "repository": "firebase/extensions",
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./lib/index.d.ts",
16
+ "default": "./lib/index.js"
17
+ },
18
+ "./lib": {
19
+ "types": "./lib/lib.d.ts",
20
+ "default": "./lib/lib.js"
21
+ }
22
+ },
9
23
  "engines": {
10
24
  "node": ">=22"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "clean": "tsc -b --clean",
29
+ "test": "vitest run",
30
+ "deploy": "pnpm build && firebase deploy --only functions",
31
+ "serve": "firebase emulators:start --only functions"
32
+ },
33
+ "dependencies": {
34
+ "@google-cloud/pubsub": "^4.3.3",
35
+ "firebase-admin": "^13.2.0",
36
+ "firebase-functions": "7.3.2",
37
+ "lodash.chunk": "^4.2.0",
38
+ "node-fetch": "^2.6.2"
39
+ },
40
+ "devDependencies": {
41
+ "@types/lodash.chunk": "^4.2.7",
42
+ "@types/node-fetch": "^2.6.2",
43
+ "vitest": "^4.1.10"
11
44
  }
12
45
  }
package/src/config.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import {
18
+ defineBoolean,
19
+ defineInt,
20
+ defineString,
21
+ expr,
22
+ projectID,
23
+ select,
24
+ storageBucket,
25
+ } from "firebase-functions/params";
26
+ import type { DeleteUserDataConfig } from "./export-config";
27
+
28
+ const instanceId = defineString("INSTANCE_ID");
29
+
30
+ const params = {
31
+ instanceId,
32
+ firestorePaths: defineString("FIRESTORE_PATHS", {
33
+ label: "Cloud Firestore paths",
34
+ description:
35
+ "Which paths in your Cloud Firestore instance contain data keyed on a user ID? Leave empty if you don't use Cloud Firestore.\nEnter the full paths, separated by commas. Use `{UID}` as a placeholder for the user's UID.\nFor example, if you have the collections `users` and `admins`, and each collection has documents with User ID as document IDs, then enter `users/{UID},admins/{UID}`.",
36
+ default: "",
37
+ input: { text: { example: "users/{UID},admins/{UID}" } },
38
+ }),
39
+ firestoreDatabaseId: defineString("FIRESTORE_DATABASE_ID", {
40
+ label: "Firestore Database ID",
41
+ description:
42
+ 'The ID of the Firestore database to use. Use "(default)" for the default database. You can view your available Firestore databases at https://console.cloud.google.com/firestore/databases.',
43
+
44
+ default: "(default)",
45
+ }),
46
+ firestoreDeleteMode: defineString("FIRESTORE_DELETE_MODE", {
47
+ label: "Cloud Firestore delete mode",
48
+ description:
49
+ "(Only applicable if you use the `Cloud Firestore paths` parameter.) How do you want to delete Cloud Firestore documents? To also delete documents in subcollections, set this parameter to `recursive`.",
50
+
51
+ default: "shallow",
52
+ input: select({ Recursive: "recursive", Shallow: "shallow" }),
53
+ }),
54
+ rtdbInstance: defineString("SELECTED_DATABASE_INSTANCE", {
55
+ label: "Realtime Database instance",
56
+ description:
57
+ "What is the ID of the Realtime Database instance from which you want to delete user data (keyed on user ID)?",
58
+
59
+ default: "",
60
+ input: {
61
+ text: {
62
+ example: "my-instance",
63
+
64
+ // Extension regex, with an empty branch added: the param is optional.
65
+ validationRegex: /^(?:[^\.\$\#\]\[\/\x00-\x1F\x7F]+|)$/,
66
+ validationErrorMessage:
67
+ "Invalid database instance. Make sure that you have entered just the instance ID, and not the entire database URL.",
68
+ },
69
+ },
70
+ }),
71
+ rtdbLocation: defineString("SELECTED_DATABASE_LOCATION", {
72
+ label: "Realtime Database location",
73
+ description:
74
+ "(Only applicable if you provided the `Realtime Database instance` parameter.) From which Realtime Database location do you want to delete data keyed on a user ID?",
75
+
76
+ default: "us-central1",
77
+ input: select({
78
+ "United States": "us-central1",
79
+ Belgium: "europe-west1",
80
+ Singapore: "asia-southeast1",
81
+ }),
82
+ }),
83
+ rtdbPaths: defineString("RTDB_PATHS", {
84
+ label: "Realtime Database paths",
85
+ description:
86
+ "Which paths in your Realtime Database instance contain data keyed on a user ID? Leave empty if you don't use Realtime Database.\nEnter the full paths, separated by commas. Use `{UID}` as a placeholder for the user's UID.\nFor example: `users/{UID},admins/{UID}`.",
87
+ default: "",
88
+ input: { text: { example: "users/{UID},admins/{UID}" } },
89
+ }),
90
+ storageBucket: defineString("CLOUD_STORAGE_BUCKET", {
91
+ label: "Cloud Storage bucket",
92
+ description:
93
+ "Which Google Cloud Storage bucket do you want to delete files from?",
94
+
95
+ default: storageBucket,
96
+ input: {
97
+ text: {
98
+ example: "my-project-12345.appspot.com",
99
+
100
+ validationRegex: /^([0-9a-z_.-]*)$/,
101
+ validationErrorMessage: "Invalid storage bucket",
102
+ },
103
+ },
104
+ }),
105
+ storagePaths: defineString("STORAGE_PATHS", {
106
+ label: "Cloud Storage paths",
107
+ description:
108
+ "Where in Google Cloud Storage do you store data keyed on a user ID? Leave empty if you don't use Cloud Storage.\nEnter the full paths to files or directories in your Storage buckets, separated by commas. Use `{UID}` to represent the User ID of the deleted user, and use `{DEFAULT}` to represent your default Storage bucket.\nHere's a series of examples. To delete all the files in your default bucket with the file naming scheme `{UID}-pic.png`, enter `{DEFAULT}/{UID}-pic.png`. To also delete all the files in another bucket called my-app-logs with the file naming scheme `{UID}-logs.txt`, enter `{DEFAULT}/{UID}-pic.png,my-app-logs/{UID}-logs.txt`. To *also* delete a User ID-labeled directory and all its files (like `media/{UID}`), enter `{DEFAULT}/{UID}-pic.png,my-app-logs/{UID}-logs.txt,{DEFAULT}/media/{UID}`.",
109
+ default: "",
110
+ input: {
111
+ text: {
112
+ example: "{DEFAULT}/{UID}-pic.png,my-awesome-app-logs/{UID}-logs.txt",
113
+ },
114
+ },
115
+ }),
116
+ enableAutoDiscovery: defineBoolean("ENABLE_AUTO_DISCOVERY", {
117
+ label: "Enable auto discovery",
118
+ description:
119
+ "Enable the extension to automatically discover Firestore collections and documents to delete.",
120
+
121
+ default: false,
122
+ }),
123
+ searchDepth: defineInt("AUTO_DISCOVERY_SEARCH_DEPTH", {
124
+ label: "Auto discovery search depth",
125
+ description:
126
+ "If auto discovery is enabled, how deep should auto discovery find collections and documents. For example, setting to `1` would only discover root collections and documents, whereas setting to `9` would search sub-collections 9 levels deep. Defaults to `3`.",
127
+ default: 3,
128
+ }),
129
+ searchFields: defineString("AUTO_DISCOVERY_SEARCH_FIELDS", {
130
+ label: "Auto discovery search fields",
131
+ description:
132
+ "If auto discovery is enabled, specify what document fields are used to associate the UID with the document. The extension will delete documents where the value for one or more of these fields matches the deleting user’s UID. If left empty, document fields will not be used in auto discovery.",
133
+
134
+ default: "id,uid,userId",
135
+ }),
136
+ searchFunction: defineString("SEARCH_FUNCTION", {
137
+ label: "Search function URL",
138
+ description:
139
+ "Specify a URL to call that will return a list of document paths to delete. The extension will send a `POST` request to the specified `URL`, with the `uid` of the deleted user will be provided in the body of the request. The endpoint specified should return an array of firestore paths to delete.",
140
+ default: "",
141
+ input: {
142
+ text: {
143
+ example:
144
+ "https://us-west1-my-project-id.cloudfunctions.net/myTransformFunction",
145
+ },
146
+ },
147
+ }),
148
+ // Non-empty defaults so Pub/Sub trigger bindings resolve during deploy
149
+ // discovery without freezing an empty topic name into the manifest.
150
+ discoveryTopicName: defineString("DISCOVERY_TOPIC_NAME", {
151
+ default: expr`kit-${instanceId}-discovery`,
152
+ }),
153
+ deletionTopicName: defineString("DELETION_TOPIC_NAME", {
154
+ default: expr`kit-${instanceId}-deletion`,
155
+ }),
156
+ };
157
+
158
+ export const CONFIG_EXPRESSIONS = {
159
+ discoveryTopicName: params.discoveryTopicName,
160
+ deletionTopicName: params.deletionTopicName,
161
+ } as const;
162
+
163
+ function optional(value: string): string | undefined {
164
+ return value.length > 0 ? value : undefined;
165
+ }
166
+
167
+ export function configFromEnv(): DeleteUserDataConfig {
168
+ return {
169
+ firestorePaths: optional(params.firestorePaths.value()),
170
+ firestoreDatabaseId: params.firestoreDatabaseId.value(),
171
+ firestoreDeleteMode:
172
+ params.firestoreDeleteMode.value() as DeleteUserDataConfig["firestoreDeleteMode"],
173
+ rtdbInstance: optional(params.rtdbInstance.value()),
174
+ rtdbLocation: optional(params.rtdbLocation.value()),
175
+ rtdbPaths: optional(params.rtdbPaths.value()),
176
+ storageBucket:
177
+ optional(params.storageBucket.value()) ?? process.env.STORAGE_BUCKET,
178
+ storagePaths: optional(params.storagePaths.value()),
179
+ enableAutoDiscovery: params.enableAutoDiscovery.value(),
180
+ searchDepth: params.searchDepth.value(),
181
+ searchFields: params.searchFields.value(),
182
+ searchFunction: optional(params.searchFunction.value()),
183
+ instanceId: params.instanceId.value(),
184
+ discoveryTopicName: optional(params.discoveryTopicName.value()),
185
+ deletionTopicName: optional(params.deletionTopicName.value()),
186
+ projectId: projectID.value(),
187
+ };
188
+ }
package/src/events.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import * as eventArc from "firebase-admin/eventarc";
18
+
19
+ let eventChannel: eventArc.Channel | undefined;
20
+
21
+ export const setupEventChannel = (): void => {
22
+ eventChannel = process.env.EVENTARC_CHANNEL
23
+ ? eventArc.getEventarc().channel(process.env.EVENTARC_CHANNEL, {
24
+ allowedEventTypes: process.env.EXT_SELECTED_EVENTS,
25
+ })
26
+ : undefined;
27
+ };
28
+
29
+ export async function publishDeletionEvent(
30
+ target: "firestore" | "database" | "storage",
31
+ data: object
32
+ ): Promise<unknown> {
33
+ if (!eventChannel) return Promise.resolve();
34
+ return eventChannel.publish({
35
+ type: `firebase.extensions.delete-user-data.v1.${target}`,
36
+ data,
37
+ });
38
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ export type FirestoreDeleteMode = "recursive" | "shallow";
18
+
19
+ export interface DeleteUserDataConfig {
20
+ firestorePaths?: string;
21
+ firestoreDatabaseId?: string;
22
+ firestoreDeleteMode?: FirestoreDeleteMode;
23
+ rtdbInstance?: string;
24
+ rtdbLocation?: string;
25
+ rtdbPaths?: string;
26
+ storageBucket?: string;
27
+ storagePaths?: string;
28
+ enableAutoDiscovery?: boolean;
29
+ searchDepth?: number;
30
+ searchFields?: string;
31
+ searchFunction?: string;
32
+ instanceId: string;
33
+ discoveryTopicName?: string;
34
+ deletionTopicName?: string;
35
+ projectId?: string;
36
+ }
37
+
38
+ export interface ResolvedDeleteUserDataConfig {
39
+ firestorePaths?: string;
40
+ firestoreDatabaseId: string;
41
+ firestoreDeleteMode: FirestoreDeleteMode;
42
+ rtdbInstance?: string;
43
+ rtdbLocation?: string;
44
+ rtdbPaths?: string;
45
+ storageBucket?: string;
46
+ storagePaths?: string;
47
+ enableAutoDiscovery: boolean;
48
+ searchDepth: number;
49
+ searchFields: string;
50
+ searchFunction?: string;
51
+ instanceId: string;
52
+ discoveryTopicName: string;
53
+ deletionTopicName: string;
54
+ projectId?: string;
55
+ }
56
+
57
+ const DEFAULT_FIRESTORE_DATABASE_ID = "(default)";
58
+ const DEFAULT_FIRESTORE_DELETE_MODE: FirestoreDeleteMode = "shallow";
59
+ const DEFAULT_SEARCH_DEPTH = 3;
60
+ const DEFAULT_SEARCH_FIELDS = "id,uid,userId";
61
+ function topicName(
62
+ name: string | undefined,
63
+ instanceId: string,
64
+ suffix: string
65
+ ): string {
66
+ return name ?? `kit-${instanceId}-${suffix}`;
67
+ }
68
+
69
+ export function resolveDeleteUserDataConfig(
70
+ config: DeleteUserDataConfig
71
+ ): ResolvedDeleteUserDataConfig {
72
+ const instanceId = config.instanceId;
73
+ return {
74
+ firestorePaths: config.firestorePaths,
75
+ firestoreDatabaseId:
76
+ config.firestoreDatabaseId ?? DEFAULT_FIRESTORE_DATABASE_ID,
77
+ firestoreDeleteMode:
78
+ config.firestoreDeleteMode ?? DEFAULT_FIRESTORE_DELETE_MODE,
79
+ rtdbInstance: config.rtdbInstance,
80
+ rtdbLocation: config.rtdbLocation,
81
+ rtdbPaths: config.rtdbPaths,
82
+ storageBucket: config.storageBucket,
83
+ storagePaths: config.storagePaths,
84
+ enableAutoDiscovery: config.enableAutoDiscovery ?? false,
85
+ searchDepth: config.searchDepth ?? DEFAULT_SEARCH_DEPTH,
86
+ searchFields: config.searchFields ?? DEFAULT_SEARCH_FIELDS,
87
+ searchFunction: config.searchFunction,
88
+ instanceId,
89
+ discoveryTopicName: topicName(
90
+ config.discoveryTopicName,
91
+ instanceId,
92
+ "discovery"
93
+ ),
94
+ deletionTopicName: topicName(
95
+ config.deletionTopicName,
96
+ instanceId,
97
+ "deletion"
98
+ ),
99
+ projectId: config.projectId,
100
+ };
101
+ }
102
+
103
+ export function getDatabaseUrl(
104
+ selectedDatabaseInstance: string | undefined,
105
+ selectedDatabaseLocation: string | undefined
106
+ ): string | null {
107
+ if (!selectedDatabaseLocation || !selectedDatabaseInstance) return null;
108
+ if (selectedDatabaseLocation === "us-central1") {
109
+ return `https://${selectedDatabaseInstance}.firebaseio.com`;
110
+ }
111
+ return `https://${selectedDatabaseInstance}.${selectedDatabaseLocation}.firebasedatabase.app`;
112
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type * as admin from "firebase-admin";
18
+ import type { DocumentReference } from "firebase-admin/firestore";
19
+ import { FieldPath } from "firebase-admin/firestore";
20
+ import chunk from "lodash.chunk";
21
+ import * as events from "./events";
22
+ import { extractUserPaths, hasValidUserPath } from "./helpers";
23
+ import * as logs from "./logs";
24
+ import { recursiveDelete } from "./recursiveDelete";
25
+ import {
26
+ type PublisherContext,
27
+ runBatchPubSubDeletions,
28
+ } from "./runBatchPubSubDeletions";
29
+ import { runCustomSearchFunction } from "./runCustomSearchFunction";
30
+ import { search } from "./search";
31
+
32
+ export interface HandlerContext extends PublisherContext {
33
+ firestore: admin.firestore.Firestore;
34
+ storage: admin.storage.Storage;
35
+ database: admin.database.Database;
36
+ }
37
+
38
+ export interface DeleteMessageData {
39
+ paths: string[];
40
+ uid: string;
41
+ }
42
+
43
+ export interface SearchMessageData {
44
+ path: string;
45
+ depth: number;
46
+ uid: string;
47
+ }
48
+
49
+ export async function handleDeletion(
50
+ data: DeleteMessageData,
51
+ ctx: HandlerContext
52
+ ): Promise<void> {
53
+ const { paths, uid } = data;
54
+ const batchArray: admin.firestore.WriteBatch[] = [];
55
+ const invalidPaths: string[] = [];
56
+
57
+ for (const chunkedPaths of chunk<string>(paths, 450)) {
58
+ const batch = ctx.firestore.batch();
59
+ for (const path of chunkedPaths) {
60
+ const docRef = ctx.firestore.doc(path);
61
+ const isValidPath = await hasValidUserPath(
62
+ docRef,
63
+ path,
64
+ uid,
65
+ ctx.config.searchFields
66
+ );
67
+
68
+ if (!isValidPath) {
69
+ invalidPaths.push(path);
70
+ continue;
71
+ }
72
+ if (ctx.config.firestoreDeleteMode === "recursive") {
73
+ await recursiveDelete(path, ctx.firestore);
74
+ } else {
75
+ batch.delete(docRef);
76
+ }
77
+ }
78
+ batchArray.push(batch);
79
+ }
80
+
81
+ await Promise.all(batchArray.map((batch) => batch.commit()));
82
+
83
+ if (invalidPaths.length > 0) {
84
+ logs.warnInvalidPaths(invalidPaths.length, uid);
85
+ }
86
+
87
+ await events.publishDeletionEvent("firestore", {
88
+ uid,
89
+ documentPaths: paths,
90
+ invalidPaths,
91
+ });
92
+ }
93
+
94
+ export async function handleSearch(
95
+ data: SearchMessageData,
96
+ ctx: HandlerContext
97
+ ): Promise<void> {
98
+ const { path, depth, uid } = data;
99
+ const nextDepth = depth + 1;
100
+ const collection = ctx.firestore.collection(path);
101
+
102
+ if (depth > ctx.config.searchDepth) return;
103
+
104
+ if (collection.id === uid) {
105
+ await recursiveDelete(path, ctx.firestore);
106
+ await events.publishDeletionEvent("firestore", {
107
+ uid,
108
+ collectionPath: collection.path,
109
+ });
110
+ return;
111
+ }
112
+
113
+ const documentReferences = await collection.listDocuments();
114
+ const documentReferencesToSearch: DocumentReference[] = [];
115
+ const pathsToDelete: string[] = [];
116
+
117
+ await Promise.all(
118
+ documentReferences.map(async (reference) => {
119
+ if (nextDepth <= ctx.config.searchDepth) {
120
+ await search(uid, nextDepth, ctx.firestore, ctx, reference);
121
+ }
122
+
123
+ if (reference.id === uid) {
124
+ pathsToDelete.push(reference.path);
125
+ } else if (ctx.config.searchFields) {
126
+ documentReferencesToSearch.push(reference);
127
+ }
128
+ })
129
+ );
130
+
131
+ if (documentReferencesToSearch.length > 0) {
132
+ const snapshots = await ctx.firestore.getAll(...documentReferencesToSearch);
133
+ for (const snapshot of snapshots) {
134
+ if (snapshot.exists) {
135
+ for (const field of ctx.config.searchFields.split(",")) {
136
+ if (snapshot.get(new FieldPath(field)) === uid) {
137
+ pathsToDelete.push(snapshot.ref.path);
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+
144
+ await runBatchPubSubDeletions({ firestorePaths: pathsToDelete }, uid, ctx);
145
+ }
146
+
147
+ export async function handleClear(
148
+ uid: string,
149
+ ctx: HandlerContext
150
+ ): Promise<void> {
151
+ logs.start(ctx.config);
152
+
153
+ const promises: Promise<void>[] = [];
154
+ if (ctx.config.firestorePaths) {
155
+ promises.push(clearFirestoreData(ctx.config.firestorePaths, uid, ctx));
156
+ } else {
157
+ logs.firestoreNotConfigured();
158
+ }
159
+ if (ctx.config.rtdbPaths) {
160
+ promises.push(clearDatabaseData(ctx.config.rtdbPaths, uid, ctx));
161
+ } else {
162
+ logs.rtdbNotConfigured();
163
+ }
164
+ if (ctx.config.storagePaths) {
165
+ promises.push(clearStorageData(ctx.config.storagePaths, uid, ctx));
166
+ } else {
167
+ logs.storageNotConfigured();
168
+ }
169
+
170
+ await Promise.all(promises);
171
+
172
+ if (ctx.config.enableAutoDiscovery) {
173
+ await search(uid, 1, ctx.firestore, ctx);
174
+ }
175
+
176
+ if (ctx.config.searchFunction) {
177
+ await runCustomSearchFunction(uid, ctx);
178
+ }
179
+
180
+ logs.complete(uid);
181
+ }
182
+
183
+ async function clearDatabaseData(
184
+ databasePaths: string,
185
+ uid: string,
186
+ ctx: HandlerContext
187
+ ): Promise<void> {
188
+ logs.rtdbDeleting();
189
+ const paths = extractUserPaths(databasePaths, uid);
190
+ await Promise.all(
191
+ paths.map(async (path) => {
192
+ try {
193
+ logs.rtdbPathDeleting(path);
194
+ await ctx.database.ref(path).remove();
195
+ logs.rtdbPathDeleted(path);
196
+ } catch (err) {
197
+ logs.rtdbPathError(path, err as Error);
198
+ }
199
+ })
200
+ );
201
+ await events.publishDeletionEvent("database", { uid, paths });
202
+ logs.rtdbDeleted();
203
+ }
204
+
205
+ async function clearStorageData(
206
+ storagePaths: string,
207
+ uid: string,
208
+ ctx: HandlerContext
209
+ ): Promise<void> {
210
+ logs.storageDeleting();
211
+ const paths = extractUserPaths(storagePaths, uid);
212
+ await Promise.all(
213
+ paths.map(async (path) => {
214
+ const parts = path.split("/");
215
+ const bucketName = parts[0];
216
+ const bucket =
217
+ bucketName === "{DEFAULT}"
218
+ ? ctx.storage.bucket(ctx.config.storageBucket)
219
+ : ctx.storage.bucket(bucketName);
220
+ const prefix = parts.slice(1).join("/");
221
+ try {
222
+ logs.storagePathDeleting(prefix);
223
+ await bucket.deleteFiles({ prefix });
224
+ logs.storagePathDeleted(prefix);
225
+ } catch (err) {
226
+ if ((err as { code?: number }).code === 404) {
227
+ logs.storagePath404(prefix);
228
+ } else {
229
+ logs.storagePathError(prefix, err as Error);
230
+ }
231
+ }
232
+ })
233
+ );
234
+ await events.publishDeletionEvent("storage", { uid, paths });
235
+ logs.storageDeleted();
236
+ }
237
+
238
+ async function clearFirestoreData(
239
+ firestorePaths: string,
240
+ uid: string,
241
+ ctx: HandlerContext
242
+ ): Promise<void> {
243
+ logs.firestoreDeleting();
244
+ const paths = extractUserPaths(firestorePaths, uid);
245
+ await Promise.all(
246
+ paths.map(async (path) => {
247
+ try {
248
+ const isRecursive = ctx.config.firestoreDeleteMode === "recursive";
249
+ if (!isRecursive) {
250
+ logs.firestorePathDeleting(path, false);
251
+ await ctx.firestore.runTransaction((transaction) => {
252
+ transaction.delete(ctx.firestore.doc(path));
253
+ return Promise.resolve();
254
+ });
255
+ logs.firestorePathDeleted(path, false);
256
+ } else {
257
+ logs.firestorePathDeleting(path, true);
258
+ await recursiveDelete(path, ctx.firestore);
259
+ logs.firestorePathDeleted(path, true);
260
+ }
261
+ } catch (err) {
262
+ logs.firestorePathError(path, err as Error);
263
+ }
264
+ })
265
+ );
266
+ await events.publishDeletionEvent("firestore", {
267
+ uid,
268
+ documentPaths: paths,
269
+ });
270
+ logs.firestoreDeleted();
271
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { type DocumentReference, FieldPath } from "firebase-admin/firestore";
18
+
19
+ export const hasValidUserPath = async (
20
+ ref: DocumentReference,
21
+ path: string,
22
+ uid: string,
23
+ searchFields: string
24
+ ): Promise<boolean> => {
25
+ if (path.includes(uid)) return true;
26
+ if (searchFields.length === 0) return false;
27
+
28
+ const snapshot = await ref.get();
29
+ if (snapshot.exists) {
30
+ for (const field of searchFields.split(",").filter(Boolean)) {
31
+ const fieldValue = snapshot.get(new FieldPath(field));
32
+ if (typeof fieldValue === "string" && fieldValue.includes(uid)) {
33
+ return true;
34
+ }
35
+ }
36
+ }
37
+
38
+ return false;
39
+ };
40
+
41
+ export const extractUserPaths = (paths: string, uid: string): string[] =>
42
+ paths.split(",").map((path) => path.replace(/{UID}/g, uid));