emberflow 1.5.1 → 2.0.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 (72) hide show
  1. package/README.md +340 -4
  2. package/firestore.indexes.json +35 -0
  3. package/lib/bin/merge-indexes.d.ts +13 -0
  4. package/lib/bin/merge-indexes.js +141 -0
  5. package/lib/bin/merge-indexes.js.map +1 -0
  6. package/lib/index-utils.d.ts +18 -7
  7. package/lib/index-utils.js +70 -75
  8. package/lib/index-utils.js.map +1 -1
  9. package/lib/index.d.ts +22 -6
  10. package/lib/index.js +43 -34
  11. package/lib/index.js.map +1 -1
  12. package/lib/logics/view-logics.d.ts +0 -2
  13. package/lib/logics/view-logics.js +1 -19
  14. package/lib/logics/view-logics.js.map +1 -1
  15. package/lib/sample-custom/cleanup-configs.d.ts +2 -0
  16. package/lib/sample-custom/cleanup-configs.js +17 -0
  17. package/lib/sample-custom/cleanup-configs.js.map +1 -0
  18. package/lib/sample-custom/index.js +14 -1
  19. package/lib/sample-custom/index.js.map +1 -1
  20. package/lib/sample-custom/one-time-patches.d.ts +2 -0
  21. package/lib/sample-custom/one-time-patches.js +14 -0
  22. package/lib/sample-custom/one-time-patches.js.map +1 -0
  23. package/lib/tests/bin/merge-indexes.test.d.ts +1 -0
  24. package/lib/tests/bin/merge-indexes.test.js +153 -0
  25. package/lib/tests/bin/merge-indexes.test.js.map +1 -0
  26. package/lib/tests/index-utils.test.js +345 -85
  27. package/lib/tests/index-utils.test.js.map +1 -1
  28. package/lib/tests/index.test.js +33 -5
  29. package/lib/tests/index.test.js.map +1 -1
  30. package/lib/tests/logics/patch-logics.test.js +50 -5
  31. package/lib/tests/logics/patch-logics.test.js.map +1 -1
  32. package/lib/tests/logics/view-logics.test.js +22 -4
  33. package/lib/tests/logics/view-logics.test.js.map +1 -1
  34. package/lib/tests/utils/cleanup.test.d.ts +1 -0
  35. package/lib/tests/utils/cleanup.test.js +243 -0
  36. package/lib/tests/utils/cleanup.test.js.map +1 -0
  37. package/lib/tests/utils/distribution.test.js +256 -1
  38. package/lib/tests/utils/distribution.test.js.map +1 -1
  39. package/lib/tests/utils/firestore-indexes.test.d.ts +1 -0
  40. package/lib/tests/utils/firestore-indexes.test.js +125 -0
  41. package/lib/tests/utils/firestore-indexes.test.js.map +1 -0
  42. package/lib/tests/utils/forms.test.js +10 -86
  43. package/lib/tests/utils/forms.test.js.map +1 -1
  44. package/lib/tests/utils/misc.test.js +96 -2
  45. package/lib/tests/utils/misc.test.js.map +1 -1
  46. package/lib/tests/utils/paths.test.js +20 -2
  47. package/lib/tests/utils/paths.test.js.map +1 -1
  48. package/lib/tests/utils/pubsub.test.js +10 -31
  49. package/lib/tests/utils/pubsub.test.js.map +1 -1
  50. package/lib/types.d.ts +51 -2
  51. package/lib/utils/cleanup.d.ts +5 -0
  52. package/lib/utils/cleanup.js +99 -0
  53. package/lib/utils/cleanup.js.map +1 -0
  54. package/lib/utils/distribution.d.ts +17 -3
  55. package/lib/utils/distribution.js +61 -23
  56. package/lib/utils/distribution.js.map +1 -1
  57. package/lib/utils/firestore-indexes.d.ts +36 -0
  58. package/lib/utils/firestore-indexes.js +80 -0
  59. package/lib/utils/firestore-indexes.js.map +1 -0
  60. package/lib/utils/forms.d.ts +0 -2
  61. package/lib/utils/forms.js +1 -35
  62. package/lib/utils/forms.js.map +1 -1
  63. package/lib/utils/misc.d.ts +1 -0
  64. package/lib/utils/misc.js +32 -1
  65. package/lib/utils/misc.js.map +1 -1
  66. package/lib/utils/pubsub.d.ts +0 -2
  67. package/lib/utils/pubsub.js +1 -16
  68. package/lib/utils/pubsub.js.map +1 -1
  69. package/package.json +6 -2
  70. package/src/sample-custom/cleanup-configs.ts +15 -0
  71. package/src/sample-custom/index.ts +15 -11
  72. 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,334 @@ 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. Emberflow's own
367
+ > collection-group cleanup fields are covered by the shipped `firestore.indexes.json` fragment
368
+ > (see **Firestore Indexes** below); composite indexes for your **own** cleanup configs must be
369
+ > declared in your project's `firestore.indexes.json` and deployed with
370
+ > `firebase deploy --only firestore:indexes`. If a rule fails, check the function logs for an
371
+ > index-creation link.
372
+
373
+ ### Firestore Indexes (`firestore.indexes.json`)
374
+
375
+ Emberflow's internal collection-group cleanup queries
376
+ (`computations`/`createdAt`, `executions`/`execDate`, `processedIds`/`timestamp`) require
377
+ single-field indexes at `COLLECTION_GROUP` scope, which Firestore does **not** auto-create. To
378
+ avoid manual `COLLECTION_GROUP_ASC` exemptions, Emberflow ships those index definitions as a static
379
+ `firestore.indexes.json` fragment at the root of the package.
380
+
381
+ Emberflow only declares the indexes **its own internal queries require**. It does **not** manage
382
+ your application's indexes — you keep those in your own `firestore.indexes.json`. This gives a
383
+ single, unambiguous owner for every index: Emberflow owns the framework indexes, your app owns the
384
+ rest.
385
+
386
+ The shipped fragment contains single-field `fieldOverrides` that enable Emberflow's internal
387
+ collection-group queries while preserving the collection-scope single-field indexes other queries
388
+ still rely on.
389
+
390
+ #### Option A — Merge with the `emberflow-indexes` CLI (recommended)
391
+
392
+ Emberflow ships an `emberflow-indexes` binary that merges the shipped fragment into your
393
+ project's `firestore.indexes.json` for you, so you never have to hand-copy the overrides.
394
+ Run it via `npx` from the directory that holds your `firestore.indexes.json`:
395
+
396
+ ```bash
397
+ npx emberflow-indexes merge
398
+ ```
399
+
400
+ By default it reads `firestore.indexes.json` in the current directory (creating it from an empty
401
+ base if it doesn't exist), merges in Emberflow's required overrides, and writes the file back.
402
+ Useful flags:
403
+
404
+ - `-p, --path <file>` — path to your `firestore.indexes.json` (default: `firestore.indexes.json`).
405
+ - `-o, --out <file>` — where to write the merged result (default: same as `--path`).
406
+ - `-f, --fragment <file>` — path to Emberflow's fragment (default: the copy shipped in the package).
407
+ - `--dry-run` — print the merged result to stdout without writing to disk.
408
+
409
+ The merge is safe to run repeatedly: it is keyed by `(collectionGroup, fieldPath)`, so it never
410
+ creates a second override for the same field. If your file already has an override for one of
411
+ Emberflow's fields, the CLI **unions the scope/order rows** into your existing entry (rather than
412
+ clobbering it), and it leaves all of your own indexes untouched.
413
+
414
+ Wire it into a Firebase **`predeploy`** hook so your indexes are always up to date before every
415
+ deploy:
416
+
417
+ ```json
418
+ // firebase.json
419
+ {
420
+ "firestore": {
421
+ "indexes": "firestore.indexes.json",
422
+ "predeploy": [
423
+ "npx emberflow-indexes merge"
424
+ ]
425
+ }
426
+ }
427
+ ```
428
+
429
+ Then deploy as usual:
430
+
431
+ ```bash
432
+ firebase deploy --only firestore:indexes
433
+ ```
434
+
435
+ #### Option B — Merge manually
436
+
437
+ If you'd rather not run the CLI, merge Emberflow's fragment into your project's
438
+ `firestore.indexes.json` by hand. The fragment lives at
439
+ `node_modules/emberflow/firestore.indexes.json` and looks like:
440
+
441
+ ```json
442
+ {
443
+ "indexes": [],
444
+ "fieldOverrides": [
445
+ {
446
+ "collectionGroup": "computations",
447
+ "fieldPath": "createdAt",
448
+ "indexes": [
449
+ { "queryScope": "COLLECTION", "order": "ASCENDING" },
450
+ { "queryScope": "COLLECTION", "order": "DESCENDING" },
451
+ { "queryScope": "COLLECTION_GROUP", "order": "ASCENDING" },
452
+ { "queryScope": "COLLECTION_GROUP", "order": "DESCENDING" }
453
+ ]
454
+ }
455
+ // ...executions/execDate and processedIds/timestamp
456
+ ]
457
+ }
458
+ ```
459
+
460
+ Add its `indexes` and `fieldOverrides` entries alongside your own, then deploy:
461
+
462
+ ```bash
463
+ firebase deploy --only firestore:indexes
464
+ ```
465
+
466
+ #### Notes
467
+
468
+ - **Field overrides**: When you add one of Emberflow's `fieldOverrides` for a field, it **replaces**
469
+ the field's automatic single-field index config — that's why each override keeps the `COLLECTION`
470
+ scope rows in addition to `COLLECTION_GROUP`, so collection-scope queries keep working. When
471
+ merging manually, never create two overrides for the same `(collectionGroup, fieldPath)`; union
472
+ the scope rows into a single entry (the CLI does this for you).
473
+ - **Idempotent**: both the CLI merge and `firebase deploy --only firestore:indexes` are declarative
474
+ — unchanged entries are left as-is, so re-running/re-deploying is safe.
475
+ - **Async builds**: A deploy only *requests* an index; it then sits in **Building** in the Firestore
476
+ console until Firestore finishes, so it may not be usable immediately after the deploy.
477
+
142
478
  ## Reference
143
479
 
144
480
  For more detailed examples on how to set up these configuration files, you can check the `src/sample-custom` folder in the Emberflow repository.
@@ -0,0 +1,35 @@
1
+ {
2
+ "indexes": [],
3
+ "fieldOverrides": [
4
+ {
5
+ "collectionGroup": "computations",
6
+ "fieldPath": "createdAt",
7
+ "indexes": [
8
+ { "queryScope": "COLLECTION", "order": "ASCENDING" },
9
+ { "queryScope": "COLLECTION", "order": "DESCENDING" },
10
+ { "queryScope": "COLLECTION_GROUP", "order": "ASCENDING" },
11
+ { "queryScope": "COLLECTION_GROUP", "order": "DESCENDING" }
12
+ ]
13
+ },
14
+ {
15
+ "collectionGroup": "executions",
16
+ "fieldPath": "execDate",
17
+ "indexes": [
18
+ { "queryScope": "COLLECTION", "order": "ASCENDING" },
19
+ { "queryScope": "COLLECTION", "order": "DESCENDING" },
20
+ { "queryScope": "COLLECTION_GROUP", "order": "ASCENDING" },
21
+ { "queryScope": "COLLECTION_GROUP", "order": "DESCENDING" }
22
+ ]
23
+ },
24
+ {
25
+ "collectionGroup": "processedIds",
26
+ "fieldPath": "timestamp",
27
+ "indexes": [
28
+ { "queryScope": "COLLECTION", "order": "ASCENDING" },
29
+ { "queryScope": "COLLECTION", "order": "DESCENDING" },
30
+ { "queryScope": "COLLECTION_GROUP", "order": "ASCENDING" },
31
+ { "queryScope": "COLLECTION_GROUP", "order": "DESCENDING" }
32
+ ]
33
+ }
34
+ ]
35
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ export interface MergeCliOptions {
3
+ command: string;
4
+ targetPath: string;
5
+ outPath: string;
6
+ fragmentPath: string;
7
+ dryRun: boolean;
8
+ }
9
+ export declare function defaultFragmentPath(): string;
10
+ export declare function parseArgs(argv: string[]): MergeCliOptions;
11
+ export declare function runMerge(options: MergeCliOptions): string;
12
+ export declare function printUsage(): void;
13
+ export declare function main(argv: string[]): void;
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || function (mod) {
20
+ if (mod && mod.__esModule) return mod;
21
+ var result = {};
22
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
23
+ __setModuleDefault(result, mod);
24
+ return result;
25
+ };
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.main = exports.printUsage = exports.runMerge = exports.parseArgs = exports.defaultFragmentPath = void 0;
28
+ const fs = __importStar(require("fs"));
29
+ const path = __importStar(require("path"));
30
+ const firestore_indexes_1 = require("../utils/firestore-indexes");
31
+ const DEFAULT_TARGET = "firestore.indexes.json";
32
+ const EMPTY_INDEXES = { indexes: [], fieldOverrides: [] };
33
+ // Path to Emberflow's shipped firestore.indexes.json fragment. When the compiled
34
+ // CLI runs from lib/bin/merge-indexes.js, the fragment sits at the package root,
35
+ // two directories up.
36
+ function defaultFragmentPath() {
37
+ return path.resolve(__dirname, "..", "..", "firestore.indexes.json");
38
+ }
39
+ exports.defaultFragmentPath = defaultFragmentPath;
40
+ function parseArgs(argv) {
41
+ var _a, _b, _c, _d;
42
+ const args = [...argv];
43
+ const command = (_a = args.shift()) !== null && _a !== void 0 ? _a : "";
44
+ let targetPath = DEFAULT_TARGET;
45
+ let outPath = "";
46
+ let fragmentPath = "";
47
+ let dryRun = false;
48
+ while (args.length > 0) {
49
+ const arg = args.shift();
50
+ switch (arg) {
51
+ case "--path":
52
+ case "-p":
53
+ targetPath = (_b = args.shift()) !== null && _b !== void 0 ? _b : targetPath;
54
+ break;
55
+ case "--out":
56
+ case "-o":
57
+ outPath = (_c = args.shift()) !== null && _c !== void 0 ? _c : outPath;
58
+ break;
59
+ case "--fragment":
60
+ case "-f":
61
+ fragmentPath = (_d = args.shift()) !== null && _d !== void 0 ? _d : fragmentPath;
62
+ break;
63
+ case "--dry-run":
64
+ dryRun = true;
65
+ break;
66
+ default:
67
+ throw new Error(`Unknown argument: ${arg}`);
68
+ }
69
+ }
70
+ return {
71
+ command,
72
+ targetPath,
73
+ outPath: outPath || targetPath,
74
+ fragmentPath: fragmentPath || defaultFragmentPath(),
75
+ dryRun,
76
+ };
77
+ }
78
+ exports.parseArgs = parseArgs;
79
+ function readIndexesFile(filePath, fallback) {
80
+ if (!fs.existsSync(filePath)) {
81
+ if (fallback) {
82
+ return fallback;
83
+ }
84
+ throw new Error(`File not found: ${filePath}`);
85
+ }
86
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
87
+ }
88
+ // Read the target and fragment files, merge them and (unless dryRun) write the
89
+ // result back. Returns the serialized merged content.
90
+ function runMerge(options) {
91
+ const fragment = readIndexesFile(options.fragmentPath);
92
+ const target = readIndexesFile(options.targetPath, EMPTY_INDEXES);
93
+ const merged = (0, firestore_indexes_1.mergeFirestoreIndexes)(target, fragment);
94
+ const output = `${JSON.stringify(merged, null, 2)}\n`;
95
+ if (options.dryRun) {
96
+ console.log(output);
97
+ return output;
98
+ }
99
+ fs.writeFileSync(options.outPath, output, "utf8");
100
+ console.log(`Merged Emberflow indexes from ${options.fragmentPath} into ${options.outPath}.`);
101
+ return output;
102
+ }
103
+ exports.runMerge = runMerge;
104
+ function printUsage() {
105
+ console.log([
106
+ "Usage: emberflow-indexes merge [options]",
107
+ "",
108
+ "Merge Emberflow's required Firestore indexes into your firestore.indexes.json.",
109
+ "",
110
+ "Options:",
111
+ " -p, --path <file> Path to your firestore.indexes.json (default: firestore.indexes.json)",
112
+ " -o, --out <file> Where to write the merged result (default: same as --path)",
113
+ " -f, --fragment <file> Path to Emberflow's fragment (default: the shipped copy)",
114
+ " --dry-run Print the merged result without writing to disk",
115
+ " -h, --help Show this help",
116
+ ].join("\n"));
117
+ }
118
+ exports.printUsage = printUsage;
119
+ function main(argv) {
120
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
121
+ printUsage();
122
+ return;
123
+ }
124
+ const options = parseArgs(argv);
125
+ if (options.command !== "merge") {
126
+ printUsage();
127
+ throw new Error(`Unknown command: ${options.command || "(none)"}`);
128
+ }
129
+ runMerge(options);
130
+ }
131
+ exports.main = main;
132
+ if (require.main === module) {
133
+ try {
134
+ main(process.argv.slice(2));
135
+ }
136
+ catch (err) {
137
+ console.error(err.message);
138
+ process.exit(1);
139
+ }
140
+ }
141
+ //# sourceMappingURL=merge-indexes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge-indexes.js","sourceRoot":"","sources":["../../src/bin/merge-indexes.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AACA,uCAAyB;AACzB,2CAA6B;AAC7B,kEAAmF;AAEnF,MAAM,cAAc,GAAG,wBAAwB,CAAC;AAChD,MAAM,aAAa,GAAqB,EAAC,OAAO,EAAE,EAAE,EAAE,cAAc,EAAE,EAAE,EAAC,CAAC;AAU1E,iFAAiF;AACjF,iFAAiF;AACjF,sBAAsB;AACtB,SAAgB,mBAAmB;IACjC,OAAO,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,wBAAwB,CAAC,CAAC;AACvE,CAAC;AAFD,kDAEC;AAED,SAAgB,SAAS,CAAC,IAAc;;IACtC,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;IACvB,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,KAAK,EAAE,mCAAI,EAAE,CAAC;IACnC,IAAI,UAAU,GAAG,cAAc,CAAC;IAChC,IAAI,OAAO,GAAG,EAAE,CAAC;IACjB,IAAI,YAAY,GAAG,EAAE,CAAC;IACtB,IAAI,MAAM,GAAG,KAAK,CAAC;IAEnB,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE;QACtB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,EAAY,CAAC;QACnC,QAAQ,GAAG,EAAE;YACb,KAAK,QAAQ,CAAC;YACd,KAAK,IAAI;gBACP,UAAU,GAAG,MAAA,IAAI,CAAC,KAAK,EAAE,mCAAI,UAAU,CAAC;gBACxC,MAAM;YACR,KAAK,OAAO,CAAC;YACb,KAAK,IAAI;gBACP,OAAO,GAAG,MAAA,IAAI,CAAC,KAAK,EAAE,mCAAI,OAAO,CAAC;gBAClC,MAAM;YACR,KAAK,YAAY,CAAC;YAClB,KAAK,IAAI;gBACP,YAAY,GAAG,MAAA,IAAI,CAAC,KAAK,EAAE,mCAAI,YAAY,CAAC;gBAC5C,MAAM;YACR,KAAK,WAAW;gBACd,MAAM,GAAG,IAAI,CAAC;gBACd,MAAM;YACR;gBACE,MAAM,IAAI,KAAK,CAAC,qBAAqB,GAAG,EAAE,CAAC,CAAC;SAC7C;KACF;IAED,OAAO;QACL,OAAO;QACP,UAAU;QACV,OAAO,EAAE,OAAO,IAAI,UAAU;QAC9B,YAAY,EAAE,YAAY,IAAI,mBAAmB,EAAE;QACnD,MAAM;KACP,CAAC;AACJ,CAAC;AAtCD,8BAsCC;AAED,SAAS,eAAe,CAAC,QAAgB,EAAE,QAA2B;IACpE,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE;QAC5B,IAAI,QAAQ,EAAE;YACZ,OAAO,QAAQ,CAAC;SACjB;QACD,MAAM,IAAI,KAAK,CAAC,mBAAmB,QAAQ,EAAE,CAAC,CAAC;KAChD;IACD,OAAO,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAqB,CAAC;AAC3E,CAAC;AAED,+EAA+E;AAC/E,sDAAsD;AACtD,SAAgB,QAAQ,CAAC,OAAwB;IAC/C,MAAM,QAAQ,GAAG,eAAe,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;IAClE,MAAM,MAAM,GAAG,IAAA,yCAAqB,EAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IACvD,MAAM,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC;IAEtD,IAAI,OAAO,CAAC,MAAM,EAAE;QAClB,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACpB,OAAO,MAAM,CAAC;KACf;IAED,EAAE,CAAC,aAAa,CAAC,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAClD,OAAO,CAAC,GAAG,CAAC,iCAAiC,OAAO,CAAC,YAAY,SAAS,OAAO,CAAC,OAAO,GAAG,CAAC,CAAC;IAC9F,OAAO,MAAM,CAAC;AAChB,CAAC;AAdD,4BAcC;AAED,SAAgB,UAAU;IACxB,OAAO,CAAC,GAAG,CAAC;QACV,0CAA0C;QAC1C,EAAE;QACF,gFAAgF;QAChF,EAAE;QACF,UAAU;QACV,gGAAgG;QAChG,qFAAqF;QACrF,mFAAmF;QACnF,0EAA0E;QAC1E,yCAAyC;KAC1C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;AAChB,CAAC;AAbD,gCAaC;AAED,SAAgB,IAAI,CAAC,IAAc;IACjC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE;QACvE,UAAU,EAAE,CAAC;QACb,OAAO;KACR;IAED,MAAM,OAAO,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,OAAO,CAAC,OAAO,KAAK,OAAO,EAAE;QAC/B,UAAU,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,oBAAoB,OAAO,CAAC,OAAO,IAAI,QAAQ,EAAE,CAAC,CAAC;KACpE;IAED,QAAQ,CAAC,OAAO,CAAC,CAAC;AACpB,CAAC;AAbD,oBAaC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE;IAC3B,IAAI;QACF,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;KAC7B;IAAC,OAAO,GAAG,EAAE;QACZ,OAAO,CAAC,KAAK,CAAE,GAAa,CAAC,OAAO,CAAC,CAAC;QACtC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;KACjB;CACF"}
@@ -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 {};