Prevent Destructive Deletes: ESLint For Nested Object Transforms

by Elias Adebayo 65 views

Hey guys! Let's dive deep into a new ESLint rule we're proposing: @blumintinc/blumint/prefer-field-paths-in-transforms. This rule aims to tackle a tricky problem we've been facing with propagation transforms.

High-Level Overview

The core issue? When using propagation transforms, like transformEach in a PropagationStrategy, returning deeply nested objects such as { a: { b: value } } can lead to unexpected destructive deletes when the source document is removed. Our pipeline is designed to skip the "after" transform on deletions. This means that when we diff a "nested before" state against {}, it often results in a parent-level REMOVE (e.g., a). Firestore interprets this as FieldValue.delete('a'), which unfortunately wipes out the entire container.

Why does this matter for BluMint? We frequently aggregate child documents into shared parent containers, such as matchesAggregation.matchPreviews. When we delete a single child, we only want to remove its specific entry, not the whole container. Removing the entire container can break other child documents within it. By enforcing flattened dot-path keys in our transforms (field paths), we can ensure that diffs produce precise, leaf-level deletes, preventing these unwanted cascading deletions.

Think of it this way: Imagine a filing cabinet (matchesAggregation) with drawers (matchPreviews). Each drawer holds files (matchId). We want to remove one file, but without this rule, we risk accidentally deleting the entire drawer or even the whole cabinet! This rule helps us target the specific file we want to remove.

Example Code (Bad vs. Good)

Let's make this super clear with some code examples. Here’s what we want to avoid:

Bad Code:

// In a propagation strategy transformEach
return {
  matchesAggregation: {
    matchPreviews: {
      [matchId]: matchPreview,
    },
  },
};

This code creates a nested object, which is precisely what we want to avoid.

Good Code:

Instead, we should use field paths (dot-notation) to target the exact leaf node:

// Use field paths (dot-notation) to target the exact leaf
return {
  [`matchesAggregation.matchPreviews.${matchId}`]: matchPreview,
};

This flattened structure ensures that deletes are targeted correctly.

Another Good Example (multiple fields):

If you need to update multiple fields, you can easily extend this approach:

return {
  [`matchesAggregation.matchPreviews.${matchId}.name`]: preview.name,
  [`matchesAggregation.matchPreviews.${matchId}.stage`]: preview.stage,
};

This approach gives you fine-grained control over updates and deletions.

Additional Notes

Let's cover some essential details:

  • Scope: This rule specifically targets idempotent transform outputs, such as transformEach in functions/src/types/propagation/propagation.ts. It does not apply to varipotent transforms (transformEachVaripotent).
  • Pipeline rationale: When a document is deleted, afterSource is intentionally set to null. As a result, transforms don't run, and the "after" state is {}. Diffing a nested "before" state against {} can lead to parent-level REMOVE operations. Flattened keys force the diff to operate at the leaf level, ensuring precise deletes. This is crucial for maintaining data integrity.
  • Type-system support: Our generics are designed to allow transforms to return either nested Partial<TTarget> or flattened Partial<Flatten<TTarget>>. This rule encourages developers to use flattened outputs for aggregation and sparse maps, aligning with the runtime behavior. It's all about consistency, guys!
  • Allowed suppression: In rare cases where deleting the entire parent is intentional, we allow inline disabling of the rule via comments. This gives us flexibility when we truly need it.

Edge Cases

Like any rule, there are edge cases to consider. Let's break them down:

  1. Intentional parent deletion:

    If deleting the entire parent container is the desired outcome (e.g., because the source exclusively owns it), an inline disable comment should be allowed. This provides an escape hatch when needed.

  2. Arrays and array operations:

    Arrays are handled by the diff's array extraction mechanism. This rule primarily focuses on nested object shapes that carry the risk of parent deletes. Returning flattened keys for arrays is still perfectly valid and often preferred. Think of it as a best practice for consistency.

  3. Mixed outputs (nested + flattened):

    Transforms might produce a mix of nested and flattened outputs. In such cases, the rule should only flag nested shapes that create multi-level objects under configured container fields (e.g., *Aggregation, *Previews). This targeted approach avoids unnecessary warnings.

  4. Dynamic keys:

    Computed dot-keys, like [`matchesAggregation.matchPreviews.${matchId}`], are encouraged and should not be flagged. These dynamic keys are a powerful way to handle complex scenarios.

  5. Non-aggregation targets:

    If a transform writes to a field that isn’t a shared container, the rule can be silent or configurable. This allows us to tailor the rule's behavior to specific contexts. Configuration is key!

Configuration Options

To make this rule as flexible as possible, we propose the following configuration options:

  • containers: An array of string patterns for field names to scope enforcement. For example, ["matchesAggregation", "groupAggregation", "previews"]. This lets you specify which containers should be checked for nested objects.
  • allowNestedIn: An array of file globs where nested outputs are allowed. This is useful for scenarios like migration scripts where nested structures might be necessary. Think of it as a whitelist for specific files.
  • severity: The default severity level for the rule, which would be `