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.
Repository.update(id, data)takes a partial of the model's top-level fields, and theFieldKeys/FieldTypemachinery inquery/query.tsis 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
updatecall, 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 entireaiProcessingmap, 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.updateand callsFirestoreService.updatewith a hand-builtRecord<string, unknown>of dotted keys — no schema encoding, no field-name checking. In one real-world codebase this was institutionalized as anunsafeUpdate(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
FieldKeysforQuery.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 theNestedFieldKeysutility is shared between queries and updates so both land on one implementation.Suggested shape
Design constraints
DateTimethree levels deep still needs its timestamp codec). That means resolving the sub-schema at each path — the currentFetch.void-over-Model.updatepipeline can't do this and needs a per-path encode step.participants.${userId}.statushave a runtime segment in the middle. A purely literal template-type API can't express that, so the design likely needs eitherSchema.Recordawareness inNestedFieldKeys(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.'aiProcessing.error': delete()), so the value type at each path should accept the matching sentinel classes, reusing theWithArrayFields-style variant machinery.A first version without dynamic-key support would already eliminate most of the raw-SDK bypasses.