emberflow 1.5.0 → 2.0.0

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 (59) hide show
  1. package/README.md +231 -4
  2. package/lib/index-utils.d.ts +18 -7
  3. package/lib/index-utils.js +71 -76
  4. package/lib/index-utils.js.map +1 -1
  5. package/lib/index.d.ts +22 -6
  6. package/lib/index.js +51 -36
  7. package/lib/index.js.map +1 -1
  8. package/lib/logics/view-logics.d.ts +0 -2
  9. package/lib/logics/view-logics.js +1 -19
  10. package/lib/logics/view-logics.js.map +1 -1
  11. package/lib/sample-custom/cleanup-configs.d.ts +2 -0
  12. package/lib/sample-custom/cleanup-configs.js +17 -0
  13. package/lib/sample-custom/cleanup-configs.js.map +1 -0
  14. package/lib/sample-custom/index.js +14 -1
  15. package/lib/sample-custom/index.js.map +1 -1
  16. package/lib/sample-custom/one-time-patches.d.ts +2 -0
  17. package/lib/sample-custom/one-time-patches.js +14 -0
  18. package/lib/sample-custom/one-time-patches.js.map +1 -0
  19. package/lib/tests/index-utils.test.js +349 -88
  20. package/lib/tests/index-utils.test.js.map +1 -1
  21. package/lib/tests/index.test.js +68 -4
  22. package/lib/tests/index.test.js.map +1 -1
  23. package/lib/tests/logics/patch-logics.test.js +50 -5
  24. package/lib/tests/logics/patch-logics.test.js.map +1 -1
  25. package/lib/tests/logics/view-logics.test.js +22 -4
  26. package/lib/tests/logics/view-logics.test.js.map +1 -1
  27. package/lib/tests/utils/cleanup.test.d.ts +1 -0
  28. package/lib/tests/utils/cleanup.test.js +243 -0
  29. package/lib/tests/utils/cleanup.test.js.map +1 -0
  30. package/lib/tests/utils/distribution.test.js +256 -1
  31. package/lib/tests/utils/distribution.test.js.map +1 -1
  32. package/lib/tests/utils/forms.test.js +10 -86
  33. package/lib/tests/utils/forms.test.js.map +1 -1
  34. package/lib/tests/utils/misc.test.js +96 -2
  35. package/lib/tests/utils/misc.test.js.map +1 -1
  36. package/lib/tests/utils/paths.test.js +20 -2
  37. package/lib/tests/utils/paths.test.js.map +1 -1
  38. package/lib/tests/utils/pubsub.test.js +10 -31
  39. package/lib/tests/utils/pubsub.test.js.map +1 -1
  40. package/lib/types.d.ts +51 -2
  41. package/lib/utils/cleanup.d.ts +5 -0
  42. package/lib/utils/cleanup.js +99 -0
  43. package/lib/utils/cleanup.js.map +1 -0
  44. package/lib/utils/distribution.d.ts +17 -3
  45. package/lib/utils/distribution.js +61 -23
  46. package/lib/utils/distribution.js.map +1 -1
  47. package/lib/utils/forms.d.ts +0 -2
  48. package/lib/utils/forms.js +1 -35
  49. package/lib/utils/forms.js.map +1 -1
  50. package/lib/utils/misc.d.ts +1 -0
  51. package/lib/utils/misc.js +32 -1
  52. package/lib/utils/misc.js.map +1 -1
  53. package/lib/utils/pubsub.d.ts +0 -2
  54. package/lib/utils/pubsub.js +1 -16
  55. package/lib/utils/pubsub.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/sample-custom/cleanup-configs.ts +15 -0
  58. package/src/sample-custom/index.ts +15 -11
  59. package/src/sample-custom/one-time-patches.ts +13 -0
package/README.md CHANGED
@@ -9,6 +9,8 @@ Emberflow is a library for Firebase Functions that simplifies the process of set
9
9
  - **Business Logics**: Define complex business rules that are automatically triggered by Firestore changes.
10
10
  - **View Logics**: Easily create and maintain denormalized data (views) across your database.
11
11
  - **Patch Logics**: Handle versioning and data migrations seamlessly.
12
+ - **Group Patch Engine**: Run one-time back-fills or bulk patch-logics runs over an entire collection, with progress tracking.
13
+ - **Pluggable Cleanup**: Automatically purge stale documents on a schedule using declarative, config-driven cleanup rules.
12
14
  - **Billing Protection**: Built-in budget monitoring to prevent unexpected costs.
13
15
 
14
16
  ## Usage
@@ -36,19 +38,23 @@ import { securityConfigs } from "./security";
36
38
  import { validatorConfigs } from "./validators";
37
39
  import { logics } from "./business-logics";
38
40
  import { patchLogicConfigs } from "./patch-logics";
41
+ import { cleanupConfigs } from "./cleanup-configs";
42
+ import { backFillPatchConfigs } from "./one-time-patches";
39
43
 
40
44
  admin.initializeApp();
41
45
 
42
- const { functionsConfig } = initializeEmberFlow(
46
+ const { functionsConfig } = initializeEmberFlow({
43
47
  projectConfig,
44
48
  admin,
45
49
  dbStructure,
46
50
  Entity,
47
51
  securityConfigs,
48
52
  validatorConfigs,
49
- logics,
50
- patchLogicConfigs
51
- );
53
+ logicConfigs: logics,
54
+ patchLogicConfigs,
55
+ cleanupConfigs, // optional, see "Collection Cleanup" below
56
+ backFillPatchConfigs, // optional, see "Group Patch Engine" below
57
+ });
52
58
 
53
59
  // Export the generated functions
54
60
  Object.entries(functionsConfig).forEach(([key, value]) => {
@@ -56,6 +62,8 @@ Object.entries(functionsConfig).forEach(([key, value]) => {
56
62
  });
57
63
  ```
58
64
 
65
+ `initializeEmberFlow` takes a single options object (`InitializeEmberFlowOptions`). `projectConfig`, `admin`, `dbStructure`, `Entity`, `securityConfigs`, `validatorConfigs`, `logicConfigs`, and `patchLogicConfigs` are required; `cleanupConfigs`, `backFillPatchConfigs`, and `userRegisterFn` are optional.
66
+
59
67
  ## Configuration
60
68
 
61
69
  Emberflow relies on several configuration objects to define how your project behaves.
@@ -139,6 +147,225 @@ export const logics: LogicConfig[] = [
139
147
  ];
140
148
  ```
141
149
 
150
+ ### Patch Logics (`patchLogicConfigs`)
151
+
152
+ Patch logics handle data migrations and versioning. They are triggered when a document's `@dataVersion` is lower than the required version defined in the patch logic configurations.
153
+
154
+ #### 1. Define the `PatchLogicFn`
155
+ A patch logic function transforms existing document data to a new version.
156
+
157
+ ```typescript
158
+ import { PatchLogicFn, LogicResult } from "emberflow/src/types";
159
+
160
+ const updateUserData: PatchLogicFn = async (dstPath, data) => {
161
+ const { fullName } = data;
162
+ const [firstName, lastName] = fullName.split(" ");
163
+
164
+ return {
165
+ name: "updateUserData",
166
+ status: "finished",
167
+ documents: [
168
+ {
169
+ action: "merge",
170
+ dstPath: dstPath,
171
+ doc: { firstName, lastName },
172
+ instructions: { fullName: "del" },
173
+ },
174
+ ],
175
+ };
176
+ };
177
+ ```
178
+
179
+ #### 2. Configure the `PatchLogicConfig`
180
+ Register the patch logic for a specific entity and version.
181
+
182
+ ```typescript
183
+ import { PatchLogicConfig } from "emberflow/src/types";
184
+
185
+ export const patchLogicConfigs: PatchLogicConfig[] = [
186
+ {
187
+ name: "updateUserData",
188
+ entity: "User",
189
+ patchLogicFn: updateUserData,
190
+ version: "1.1.0", // The version this patch achieves
191
+ },
192
+ ];
193
+ ```
194
+
195
+ #### 3. How it Works
196
+ - **Triggering**: There are two ways patch logics run:
197
+ - **Automatic, per-document (default)**: You only register `patchLogicConfigs`. Emberflow
198
+ automatically queues and runs them for a single document during form submissions or
199
+ document distribution whenever a version mismatch is detected — projects don't call
200
+ anything directly here.
201
+ - **On-demand, collection-wide (bulk migration)**: To re-run patch logics across every
202
+ existing document in a collection, call the public `queueGroupPatch({ path, patchType:
203
+ "patch-logics", appVersion })` (see the [Group Patch Engine](#group-patch-engine-queuegrouppatch-getgrouppatchprogress-backfillpatchconfig)
204
+ below). Progress can be polled with `getGroupPatchProgress`.
205
+ - **Asynchronous Execution**: They run asynchronously via Pub/Sub to ensure high performance.
206
+ - **Version-gating**: In both cases a `PatchLogicConfig` only fires when `config.version <=`
207
+ the current `appVersion` **and** the document's `@dataVersion` is older than `config.version`.
208
+ After a successful patch, the document's `@dataVersion` is bumped so it won't run again.
209
+ - **Versioning**:
210
+ - **`@dataVersion`**: Incremented automatically after a patch is successfully applied.
211
+ - **`minDataVersion`**: In `LogicConfig`, use this to ensure business logic only runs on compatible data.
212
+ - **`obsoleteStartingFromVersion`**: In `LogicConfig`, use this to retire old logic based on the `appVersion`.
213
+ - **Transactions**: Executed within Firestore transactions to ensure data integrity.
214
+
215
+ ### Group Patch Engine (`queueGroupPatch`, `getGroupPatchProgress`, `BackFillPatchConfig`)
216
+
217
+ Emberflow ships with a generic, collection-wide batch engine for running a patch over every
218
+ document in a collection (paging 500 docs at a time, tracking progress, and self-rescheduling
219
+ over Pub/Sub until done). Each run is identified by a `patchType`:
220
+
221
+ - **`"back-fill"`**: a version-free, one-time bulk back-fill. The actual work is delegated to a
222
+ `BackFillPatchConfig` resolved by `backFillPatchName`. Emberflow ships with a built-in
223
+ `ancestorIdsPatchConfig` (named `"ancestor-ids"`) that populates the `@entity`/ancestor id
224
+ fields used internally — it is **always registered automatically**, so you can trigger it with
225
+ `queueGroupPatch` at any time without registering it yourself. `appVersion` is **not** used for
226
+ `back-fill` runs (it's only required for `"patch-logics"`).
227
+ - **`"patch-logics"`**: runs `runPatchLogics(appVersion, path)` for every document in the
228
+ collection, useful for bulk-applying `patchLogicConfigs` migrations.
229
+
230
+ #### 1. Register a custom `BackFillPatchConfig`
231
+
232
+ ```typescript
233
+ import { BackFillPatchConfig } from "emberflow/src/types";
234
+
235
+ const myBackFill: BackFillPatchConfig = {
236
+ name: "my-back-fill", // used as backFillPatchName; must be unique
237
+ patchFn: async (collectionPath, docs) => {
238
+ // Bulk-update `docs` here, e.g. via a single db.batch() commit.
239
+ },
240
+ };
241
+
242
+ export const backFillPatchConfigs: BackFillPatchConfig[] = [myBackFill];
243
+ ```
244
+
245
+ Pass `backFillPatchConfigs` to `initializeEmberFlow` (see step 2 above). The built-in
246
+ `"ancestor-ids"` config is always registered automatically; registering a config with a
247
+ duplicate name, or the reserved name `"ancestor-ids"`, throws during initialization.
248
+
249
+ #### 2. Trigger a group patch
250
+
251
+ ```typescript
252
+ import { queueGroupPatch } from "emberflow";
253
+
254
+ // Kick off the built-in ancestor-ids back-fill for a collection
255
+ await queueGroupPatch({
256
+ path: "/users/user123/feeds",
257
+ patchType: "back-fill",
258
+ backFillPatchName: "ancestor-ids",
259
+ });
260
+
261
+ // Kick off your own back-fill
262
+ await queueGroupPatch({
263
+ path: "/users/user123/feeds",
264
+ patchType: "back-fill",
265
+ backFillPatchName: "my-back-fill",
266
+ });
267
+
268
+ // Bulk-apply patch logics for a target appVersion
269
+ await queueGroupPatch({
270
+ path: "/users/user123/feeds",
271
+ patchType: "patch-logics",
272
+ appVersion: "1.2.0",
273
+ });
274
+ ```
275
+
276
+ `queueGroupPatch` accepts either a collection path or a document path (in which case the parent
277
+ collection is derived). There is no guard/auth on it — it's meant to be called from your own
278
+ trusted code (e.g. an admin-only Cloud Function or script). Placeholder paths (e.g.
279
+ `/users/{userId}/feeds`) are automatically hydrated into concrete collection paths before patching.
280
+
281
+ > If `backFillPatchName` doesn't resolve to a registered `BackFillPatchConfig` (or a
282
+ > `"patch-logics"` run is missing its `appVersion`), the run is set to status `"error"` — there is
283
+ > no silent default.
284
+
285
+ #### 3. Track progress
286
+
287
+ ```typescript
288
+ import { getGroupPatchProgress } from "emberflow";
289
+
290
+ const progress = await getGroupPatchProgress({
291
+ collectionPath: "/users/user123/feeds",
292
+ patchType: "back-fill",
293
+ backFillPatchName: "ancestor-ids",
294
+ });
295
+ // progress?.status -> "running" | "completed" | "error" | "reset"
296
+ ```
297
+
298
+ Progress is tracked independently per `patchType`/`backFillPatchName`, so different patches
299
+ running against the same collection never clobber each other's status. The status doc lives at
300
+ `@emberflow/internal/group-patches/<collection>_back-fill_<backFillPatchName>` (or
301
+ `..._patch-logics` for `"patch-logics"` runs).
302
+
303
+ ### Collection Cleanup (`cleanupConfigs`, `CleanupConfig`)
304
+
305
+ Emberflow ships with a single, scheduled `cleanupCollections` Cloud Function that runs **every
306
+ hour** and purges stale documents based on declarative rules. Instead of writing a bespoke
307
+ scheduled function for each collection you want to prune, you describe *what* to delete with a
308
+ `CleanupConfig` and Emberflow handles the *how* (querying, batching, recursive subtree deletion,
309
+ and self-paced iteration).
310
+
311
+ Two layers of rules are merged and executed by the same runner:
312
+
313
+ 1. **Built-in (framework) rules** — always active. They keep Emberflow's own internal
314
+ bookkeeping collections tidy (Pub/Sub `processedIds`, metric `executions`/`computations`,
315
+ view-logic executions, and `@actions` — the latter also nulls out the corresponding
316
+ `forms/{uid}/{formId}` entries in the Realtime Database).
317
+ 2. **Project-supplied rules** — whatever you pass via the optional `cleanupConfigs` init option.
318
+ These are appended to the built-in rules, so your rules run alongside them.
319
+
320
+ #### 1. Define a `CleanupConfig`
321
+
322
+ ```typescript
323
+ import { CleanupConfig } from "emberflow/src/types";
324
+
325
+ export const cleanupConfigs: CleanupConfig[] = [
326
+ {
327
+ // Exact collection path, or a collection-group name when isCollectionGroup=true.
328
+ collectionPath: "askJaris",
329
+ // Match this subcollection name anywhere in Firestore (collection-group query).
330
+ isCollectionGroup: true,
331
+ // Timestamp/Date field compared against the computed cutoff.
332
+ timestampField: "createdAt",
333
+ // Delete docs whose timestampField is older than (value · unit).
334
+ // unit is one of "hours" | "days" | "months".
335
+ olderThan: { value: 1, unit: "months" },
336
+ // Optional extra server-side filters, ANDed with the age threshold.
337
+ conditions: [
338
+ { fieldName: "hasTopic", operator: "==", value: false },
339
+ ],
340
+ // recursive defaults to true: each matched doc is deleted with its whole
341
+ // subtree. Set to false to delete only the matched documents.
342
+ recursive: true,
343
+ },
344
+ ];
345
+ ```
346
+
347
+ Then pass `cleanupConfigs` to `initializeEmberFlow` (see step 2 in **Usage** above).
348
+
349
+ #### 2. How it Works
350
+
351
+ - **Scheduling**: A single `cleanupCollections` scheduled function runs `every 1 hours`. You can
352
+ override its schedule/region/memory/timeout via `projectConfig.functionsConfig.cleanupCollections`.
353
+ - **Cutoff computation**: `olderThan` is converted to a cutoff `Date`; documents whose
354
+ `timestampField` is `< cutoff` are selected. `"months"` is calendar-aware (it subtracts
355
+ calendar months rather than a fixed number of days).
356
+ - **Extra filters (`conditions`)**: Optional `QueryCondition` entries (`{ fieldName, operator,
357
+ value }`) are ANDed with the age threshold as additional server-side `where` clauses.
358
+ - **Deletion mode (`recursive`)**: Defaults to `true`, deleting each matched document together
359
+ with its entire subtree. Set `recursive: false` to delete only the matched documents (leaving
360
+ any subcollections untouched).
361
+ - **Isolation**: Each config is executed independently inside its own `try/catch`, so a failure
362
+ in one rule (e.g. a missing index) won't stop the others; failures are logged and the number of
363
+ deleted documents per collection is reported to the logs.
364
+
365
+ > **Composite indexes**: Collection-group queries and any `conditions` combined with the
366
+ > timestamp filter may require composite Firestore indexes in your project. If a rule fails,
367
+ > check the function logs for an index-creation link.
368
+
142
369
  ## Reference
143
370
 
144
371
  For more detailed examples on how to set up these configuration files, you can check the `src/sample-custom` folder in the Emberflow repository.
@@ -1,4 +1,4 @@
1
- import { Action, LogicResult, LogicResultDoc, MetricExecution, RunBusinessLogicStatus, SecurityFn, TxnGet, ValidateFormResult } from "./types";
1
+ import { Action, GroupPatchType, LogicResult, LogicResultDoc, MetricExecution, BackFillPatchConfig, RunBusinessLogicStatus, SecurityFn, TxnGet, ValidateFormResult } from "./types";
2
2
  import { database, firestore } from "firebase-admin";
3
3
  import { BatchUtil } from "./utils/batch";
4
4
  import type { FirestoreEvent } from "firebase-functions/v2/firestore";
@@ -10,6 +10,7 @@ import Transaction = firestore.Transaction;
10
10
  export declare const _mockable: {
11
11
  getViewLogicConfigs: () => import("./types").ViewLogicConfig[];
12
12
  getPatchLogicConfigs: () => import("./types").PatchLogicConfig[];
13
+ getOneTimePatchConfigs: () => BackFillPatchConfig[];
13
14
  createNowTimestamp: () => firestore.Timestamp;
14
15
  saveMetricExecution: typeof saveMetricExecution;
15
16
  getBatchUtil: () => BatchUtil;
@@ -33,14 +34,24 @@ export declare function onDeleteFunction(event: FirestoreEvent<QueryDocumentSnap
33
34
  export declare function createMetricLogicDoc(logicName: string): Promise<void>;
34
35
  export declare function convertLogicResultsToMetricExecutions(logicResults: LogicResult[]): MetricExecution[];
35
36
  declare function saveMetricExecution(metricExecutions: MetricExecution[]): Promise<void>;
36
- export declare function cleanMetricExecutions(_event: ScheduledEvent): Promise<void>;
37
37
  export declare function createMetricComputation(_event: ScheduledEvent): Promise<void>;
38
- export declare function cleanMetricComputations(_event: ScheduledEvent): Promise<void>;
39
38
  export declare function distributeFnTransactional(txn: Transaction, logicResults: LogicResult[], appVersion: string): Promise<LogicResultDoc[]>;
40
39
  /**
41
- * Internal logic for patching siblings. Exported for background worker.
42
- * @param {string} collectionPath The path to the collection to patch.
43
- * @param {string} lastPatchedId The ID of the last document patched in the previous batch.
40
+ * Built-in, version-free bulk back-fill that restores the original ancestor-ids
41
+ * patching behavior. Runs `addAncestorIds` per doc and commits all updates in a
42
+ * single `db.batch()`, only updating keys that are currently undefined.
44
43
  */
45
- export declare function patchSiblingsWithAncestorIds(collectionPath: string, lastPatchedId?: string): Promise<void>;
44
+ export declare const ancestorIdsPatchConfig: BackFillPatchConfig;
45
+ export interface PatchGroupDocsParams {
46
+ collectionPath: string;
47
+ patchType: GroupPatchType;
48
+ backFillPatchName?: string;
49
+ appVersion?: string;
50
+ lastPatchedId?: string;
51
+ }
52
+ /**
53
+ * Internal engine logic for patching a collection page-by-page. Exported for background worker.
54
+ * @param {PatchGroupDocsParams} params The collection path, patch type, and cursor.
55
+ */
56
+ export declare function patchGroupDocs(params: PatchGroupDocsParams): Promise<void>;
46
57
  export {};
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.patchSiblingsWithAncestorIds = exports.distributeFnTransactional = exports.cleanMetricComputations = exports.createMetricComputation = exports.cleanMetricExecutions = exports.convertLogicResultsToMetricExecutions = exports.createMetricLogicDoc = exports.onDeleteFunction = exports.expandConsolidateAndGroupByDstPath = exports.getSecurityFn = exports.groupDocsByTargetDocPath = exports.runBusinessLogics = exports.delayFormSubmissionAndCheckIfCancelled = exports.getFormModifiedFields = exports.validateForm = exports.distributeLater = exports.distributeFnNonTransactional = exports.distributeDoc = exports._mockable = void 0;
3
+ exports.patchGroupDocs = exports.ancestorIdsPatchConfig = exports.distributeFnTransactional = exports.createMetricComputation = exports.convertLogicResultsToMetricExecutions = exports.createMetricLogicDoc = exports.onDeleteFunction = exports.expandConsolidateAndGroupByDstPath = exports.getSecurityFn = exports.groupDocsByTargetDocPath = exports.runBusinessLogics = exports.delayFormSubmissionAndCheckIfCancelled = exports.getFormModifiedFields = exports.validateForm = exports.distributeLater = exports.distributeFnNonTransactional = exports.distributeDoc = exports._mockable = void 0;
4
4
  const firebase_admin_1 = require("firebase-admin");
5
5
  const index_1 = require("./index");
6
6
  const paths_1 = require("./utils/paths");
@@ -15,6 +15,7 @@ var Timestamp = firebase_admin_1.firestore.Timestamp;
15
15
  exports._mockable = {
16
16
  getViewLogicConfigs: () => index_1.viewLogicConfigs,
17
17
  getPatchLogicConfigs: () => index_1.patchLogicConfigs,
18
+ getOneTimePatchConfigs: () => index_1.backFillPatchConfigs,
18
19
  createNowTimestamp: () => index_1.admin.firestore.Timestamp.now(),
19
20
  saveMetricExecution: saveMetricExecution,
20
21
  getBatchUtil: () => batch_1.BatchUtil.getInstance(),
@@ -91,18 +92,6 @@ async function distributeDoc(logicResultDoc, appVersion, batch, txn) {
91
92
  if (doc) {
92
93
  if (action === "create") {
93
94
  (0, paths_1.addAncestorIds)(dstPath, doc);
94
- // Only patch siblings in real environment, not during tests that expect a specific number of firestore calls
95
- if (process.env.JEST_WORKER_ID === undefined) {
96
- const collectionPath = (0, paths_1.getParentPath)(dstPath);
97
- if (collectionPath && collectionPath.includes("/")) {
98
- const patchStatusPath = `@emberflow/internal/group-query-patches/${collectionPath.replace(/\//g, "_")}`;
99
- const patchStatusRef = index_1.db.doc(patchStatusPath);
100
- const patchStatusDoc = await patchStatusRef.get();
101
- if (!patchStatusDoc.exists) {
102
- await (0, distribution_1.queueAncestorIdsPatch)(dstPath);
103
- }
104
- }
105
- }
106
95
  }
107
96
  let updateData = {};
108
97
  if (destProp) {
@@ -140,7 +129,7 @@ async function distributeDoc(logicResultDoc, appVersion, batch, txn) {
140
129
  }
141
130
  else if (action === "submit-form") {
142
131
  if (txn) {
143
- console.error("Submit-form is not supported in transactional logic result");
132
+ console.debug("Submit-form in transactional logic result will be handled after transaction success");
144
133
  }
145
134
  else {
146
135
  console.debug("Queuing submit form...");
@@ -499,20 +488,6 @@ async function saveMetricExecution(metricExecutions) {
499
488
  });
500
489
  }
501
490
  }
502
- async function cleanMetricExecutions(_event) {
503
- console.info("Running cleanMetricExecutions");
504
- const metricsSnapshot = await index_1.db.collection("@metrics").get();
505
- let i = 0;
506
- for (const metricDoc of metricsSnapshot.docs) {
507
- const query = metricDoc.ref.collection("executions")
508
- .where("execDate", "<", new Date(Date.now() - 1000 * 60 * 60 * 24 * 7));
509
- await (0, misc_1.deleteCollection)(query, (snapshot) => {
510
- i += snapshot.size;
511
- });
512
- }
513
- console.info(`Cleaned ${i} logic metric executions`);
514
- }
515
- exports.cleanMetricExecutions = cleanMetricExecutions;
516
491
  async function createMetricComputation(_event) {
517
492
  console.info("Creating metric computation");
518
493
  const metricsSnapshot = await index_1.db.collection("@metrics").get();
@@ -556,20 +531,6 @@ async function createMetricComputation(_event) {
556
531
  }
557
532
  }
558
533
  exports.createMetricComputation = createMetricComputation;
559
- async function cleanMetricComputations(_event) {
560
- console.info("Running cleanMetricComputations");
561
- const metricsSnapshot = await index_1.db.collection("@metrics").get();
562
- let i = 0;
563
- for (const metricDoc of metricsSnapshot.docs) {
564
- const query = metricDoc.ref.collection("computations")
565
- .where("createdAt", "<", new Date(Date.now() - 1000 * 60 * 60 * 24 * 30));
566
- await (0, misc_1.deleteCollection)(query, (snapshot) => {
567
- i += snapshot.size;
568
- });
569
- }
570
- console.info(`Cleaned ${i} logic metric computations`);
571
- }
572
- exports.cleanMetricComputations = cleanMetricComputations;
573
534
  async function distributeFnTransactional(txn, logicResults, appVersion) {
574
535
  const distributedLogicResultDocs = [];
575
536
  const transactionalResults = logicResults.filter((result) => result.transactional);
@@ -590,12 +551,40 @@ async function distributeFnTransactional(txn, logicResults, appVersion) {
590
551
  }
591
552
  exports.distributeFnTransactional = distributeFnTransactional;
592
553
  /**
593
- * Internal logic for patching siblings. Exported for background worker.
594
- * @param {string} collectionPath The path to the collection to patch.
595
- * @param {string} lastPatchedId The ID of the last document patched in the previous batch.
554
+ * Built-in, version-free bulk back-fill that restores the original ancestor-ids
555
+ * patching behavior. Runs `addAncestorIds` per doc and commits all updates in a
556
+ * single `db.batch()`, only updating keys that are currently undefined.
596
557
  */
597
- async function patchSiblingsWithAncestorIds(collectionPath, lastPatchedId) {
598
- const patchStatusPath = `@emberflow/internal/group-query-patches/${collectionPath.replace(/\//g, "_")}`;
558
+ exports.ancestorIdsPatchConfig = {
559
+ name: "ancestor-ids",
560
+ patchFn: async (_collectionPath, docs) => {
561
+ const currentBatch = index_1.db.batch();
562
+ let count = 0;
563
+ for (const doc of docs) {
564
+ const data = doc.data();
565
+ const updatedData = {};
566
+ const fullPath = doc.ref.path.startsWith("/") ? doc.ref.path : `/${doc.ref.path}`;
567
+ (0, paths_1.addAncestorIds)(fullPath, updatedData);
568
+ const keysToUpdate = Object.keys(updatedData).filter((key) => data[key] === undefined);
569
+ if (keysToUpdate.length > 0) {
570
+ const patchData = {};
571
+ keysToUpdate.forEach((key) => patchData[key] = updatedData[key]);
572
+ currentBatch.update(doc.ref, patchData);
573
+ count++;
574
+ }
575
+ }
576
+ if (count > 0) {
577
+ await currentBatch.commit();
578
+ }
579
+ },
580
+ };
581
+ /**
582
+ * Internal engine logic for patching a collection page-by-page. Exported for background worker.
583
+ * @param {PatchGroupDocsParams} params The collection path, patch type, and cursor.
584
+ */
585
+ async function patchGroupDocs(params) {
586
+ const { collectionPath, patchType, backFillPatchName, appVersion, lastPatchedId } = params;
587
+ const patchStatusPath = (0, distribution_1.getGroupPatchStatusPath)(collectionPath, patchType, backFillPatchName);
599
588
  const patchStatusRef = index_1.db.doc(patchStatusPath);
600
589
  const patchStatusDoc = await patchStatusRef.get();
601
590
  const patchStatusData = patchStatusDoc.data();
@@ -604,59 +593,65 @@ async function patchSiblingsWithAncestorIds(collectionPath, lastPatchedId) {
604
593
  }
605
594
  // Handle "reset" status by starting from scratch
606
595
  const effectiveLastId = (patchStatusData === null || patchStatusData === void 0 ? void 0 : patchStatusData.status) === "reset" ? undefined : lastPatchedId;
607
- console.info(`[AncestorIdsPatch] Patching siblings for collection: ${collectionPath}${effectiveLastId ? ` starting from ${effectiveLastId}` : ""}`);
596
+ console.info(`[GroupPatch] Patching docs for collection: ${collectionPath}${effectiveLastId ? ` starting from ${effectiveLastId}` : ""}`);
608
597
  let query = index_1.db.collection(collectionPath).orderBy(index_1.admin.firestore.FieldPath.documentId());
609
598
  if (effectiveLastId) {
610
599
  query = query.startAfter(effectiveLastId);
611
600
  }
612
- const siblingsSnapshot = await query.limit(500).get();
613
- if (siblingsSnapshot.empty) {
601
+ const docsSnapshot = await query.limit(500).get();
602
+ if (docsSnapshot.empty) {
614
603
  await patchStatusRef.set({
615
604
  status: "completed",
605
+ patchType,
606
+ backFillPatchName: backFillPatchName !== null && backFillPatchName !== void 0 ? backFillPatchName : null,
616
607
  patchedAt: index_1.admin.firestore.Timestamp.now(),
617
608
  collectionPath,
618
609
  }, { merge: true });
619
- console.info(`[AncestorIdsPatch] Completed patching for collection: ${collectionPath}`);
610
+ console.info(`[GroupPatch] Completed patching for collection: ${collectionPath}`);
620
611
  return;
621
612
  }
622
- const currentBatch = index_1.db.batch();
623
- let count = 0;
624
- let lastId = lastPatchedId;
625
- for (const siblingDoc of siblingsSnapshot.docs) {
626
- const data = siblingDoc.data();
627
- const updatedData = {};
628
- const fullPath = siblingDoc.ref.path.startsWith("/") ? siblingDoc.ref.path : `/${siblingDoc.ref.path}`;
629
- (0, paths_1.addAncestorIds)(fullPath, updatedData);
630
- const keysToUpdate = Object.keys(updatedData).filter((key) => data[key] === undefined);
631
- if (keysToUpdate.length > 0) {
632
- const patchData = {};
633
- keysToUpdate.forEach((key) => patchData[key] = updatedData[key]);
634
- currentBatch.update(siblingDoc.ref, patchData);
635
- count++;
636
- }
637
- lastId = siblingDoc.id;
638
- }
613
+ const lastId = docsSnapshot.docs[docsSnapshot.docs.length - 1].id;
639
614
  try {
640
- if (count > 0) {
641
- await currentBatch.commit();
615
+ if (patchType === "patch-logics") {
616
+ if (!appVersion) {
617
+ throw new Error(`Missing appVersion for patch-logics on collection: ${collectionPath}`);
618
+ }
619
+ for (const doc of docsSnapshot.docs) {
620
+ const fullPath = doc.ref.path.startsWith("/") ? doc.ref.path : `/${doc.ref.path}`;
621
+ await (0, patch_logics_1.runPatchLogics)(appVersion, fullPath);
622
+ }
623
+ }
624
+ else if (patchType === "back-fill") {
625
+ const config = exports._mockable.getOneTimePatchConfigs().find((c) => c.name === backFillPatchName);
626
+ if (!config) {
627
+ throw new Error(`Unresolved back-fill patch "${backFillPatchName}" for collection: ${collectionPath}`);
628
+ }
629
+ await config.patchFn(collectionPath, docsSnapshot.docs);
630
+ }
631
+ else {
632
+ throw new Error(`Unknown patchType "${patchType}" for collection: ${collectionPath}`);
642
633
  }
643
- const totalPatched = ((patchStatusData === null || patchStatusData === void 0 ? void 0 : patchStatusData.status) === "reset" ? 0 : ((patchStatusData === null || patchStatusData === void 0 ? void 0 : patchStatusData.count) || 0)) + count;
634
+ const totalPatched = ((patchStatusData === null || patchStatusData === void 0 ? void 0 : patchStatusData.status) === "reset" ? 0 : ((patchStatusData === null || patchStatusData === void 0 ? void 0 : patchStatusData.count) || 0)) + docsSnapshot.docs.length;
644
635
  await patchStatusRef.set({
645
- status: "processing",
636
+ status: "running",
637
+ patchType,
638
+ backFillPatchName: backFillPatchName !== null && backFillPatchName !== void 0 ? backFillPatchName : null,
646
639
  lastPatchedId: lastId,
647
640
  collectionPath,
648
641
  count: totalPatched,
649
642
  updatedAt: index_1.admin.firestore.Timestamp.now(),
650
643
  }, { merge: true });
651
- console.info(`[AncestorIdsPatch] Batch Complete: Patched ${count} siblings for collection: ${collectionPath}. Total patched so far: ${totalPatched}. Rescheduling next batch...`);
644
+ console.info(`[GroupPatch] Batch Complete: Patched ${docsSnapshot.docs.length} docs for collection: ${collectionPath}. Total patched so far: ${totalPatched}. Rescheduling next batch...`);
652
645
  // Reschedule for next batch
653
- await (0, distribution_1.queueAncestorIdsPatch)(collectionPath, lastId);
646
+ await (0, distribution_1.queueGroupPatch)({ path: collectionPath, patchType, backFillPatchName, appVersion, lastPatchedId: lastId });
654
647
  }
655
648
  catch (error) {
656
- console.error(`[AncestorIdsPatch] Error in patchSiblingsWithAncestorIds for ${collectionPath}:`, error);
649
+ console.error(`[GroupPatch] Error in patchGroupDocs for ${collectionPath}:`, error);
657
650
  // On error, we set status to error and store the error message
658
651
  await patchStatusRef.set({
659
652
  status: "error",
653
+ patchType,
654
+ backFillPatchName: backFillPatchName !== null && backFillPatchName !== void 0 ? backFillPatchName : null,
660
655
  error: error instanceof Error ? error.message : String(error),
661
656
  updatedAt: index_1.admin.firestore.Timestamp.now(),
662
657
  }, { merge: true });
@@ -666,5 +661,5 @@ async function patchSiblingsWithAncestorIds(collectionPath, lastPatchedId) {
666
661
  throw error;
667
662
  }
668
663
  }
669
- exports.patchSiblingsWithAncestorIds = patchSiblingsWithAncestorIds;
664
+ exports.patchGroupDocs = patchGroupDocs;
670
665
  //# sourceMappingURL=index-utils.js.map