Skip to content

Typed nested-path (dotted) updates through the repository #63

Description

@fwal

Repository.update(id, data) takes a partial of the model's top-level fields, and the FieldKeys/FieldType machinery in query/query.ts is also strictly top-level. There is no typed way to express a Firestore dotted-path write like 'aiProcessing.error'.

Motivation

Firestore's update semantics make this a correctness trap, not just a convenience issue. In an update call, a key containing a dot — 'aiProcessing.error' — means patch this one nested field, leave its siblings alone, while a plain nested object — { aiProcessing: { error } } — means replace the entire aiProcessing map, silently deleting every sibling field. So patching one field of a nested map safely is impossible through the current typed API: the types accept the nested-object form and it destroys data.

In practice, every repository that patches nested state bypasses Repository.update and calls FirestoreService.update with a hand-built Record<string, unknown> of dotted keys — no schema encoding, no field-name checking. In one real-world codebase this was institutionalized as an unsafeUpdate(id, Record<string, unknown>) method, which downstream code then standardized on because it's the only API that can express the writes it needs. The escape hatch became the API.

Relation to existing work

#18 and PR #25 cover the query side (nested FieldKeys for Query.where/orderBy). The recursive key type there is exactly the right building block, but PR #25 targets the pre-Effect-v4 codebase, and nothing covers the write side — which is where the real-world escape hatches are. Ideally the NestedFieldKeys utility is shared between queries and updates so both land on one implementation.

Suggested shape

// Type level: recursive dotted keys with a depth cap, and the value type at a path
type NestedFieldKeys<S>    // 'aiProcessing' | 'aiProcessing.error' | 'aiProcessing.status' | ...
type NestedFieldType<S, K> // the type at that path

// API level, on Repository:
readonly updateFields: (
  id: IdSchema['Type'],
  fields: { [K in NestedFieldKeys<S>]?: NestedFieldType<S, K> | Sentinel }
) => Effect.Effect<void, ModelError, ...>;

Design constraints

  • Per-path encoding: values at a dotted path must still be encoded per-field (a DateTime three levels deep still needs its timestamp codec). That means resolving the sub-schema at each path — the current Fetch.void-over-Model.update pipeline can't do this and needs a per-path encode step.
  • Dynamic keys: patterns like participants.${userId}.status have a runtime segment in the middle. A purely literal template-type API can't express that, so the design likely needs either Schema.Record awareness in NestedFieldKeys (allowing `participants.${string}.status`) or a builder-style API (patch.at('participants', userId, 'status').set(...)) that carries types through call arguments instead of string parsing.
  • Sentinels: dotted paths are where sentinels concentrate (e.g. 'aiProcessing.error': delete()), so the value type at each path should accept the matching sentinel classes, reusing the WithArrayFields-style variant machinery.
  • Depth cap: the recursive key type needs a depth limit (3–4 levels) to keep compile times sane on large models.

A first version without dynamic-key support would already eliminate most of the raw-SDK bypasses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions