Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,27 @@ for a document you expect to be new, `'update'` with `merge: true` for one
you expect to exist. Both are assertions rather than checks — `set` will
not verify which case it is actually in.

### Updating nested fields

`update` accepts Firestore dotted field paths, typed against the model, so a
nested field can change without rewriting its siblings. A whole-field key
replaces the entire map, as in the Firestore SDKs.

```typescript
yield * repo.update(postId, { 'metaData.deleted': true }); // touches only metaData.deleted
yield * repo.update(postId, { metaData: { deleted: true, tags: [] } }); // replaces metaData
yield * repo.update(postId, { 'stats.likes': Firestore.increment(1) }); // nested sentinel
yield * repo.update(postId, { metaData: { deleted: true } }, { merge: true }); // flattened to metaData.deleted
```

With `{ merge: true }` the payload is a deep partial: nested objects are
flattened into dotted paths before the write, so absent siblings are left
untouched. Arrays, `DateTime`, sentinels and other non-plain values are
written whole.

Keys the model does not declare fail with a `SchemaError` naming the key; an
empty payload fails with `FirestoreError` code `invalid-argument`.

### Client app

```typescript
Expand Down
67 changes: 54 additions & 13 deletions packages/effect-firebase/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,19 @@ service via `Admin.layer`/`Client.layer`/mock).

Repository methods (all fail with `ModelError = FirestoreError | UnknownError | NoSuchElementError | SchemaError`):

| Method | Returns | Notes |
| ------------------------------------- | ------------------------------ | --------------------------------------------------------------------- |
| `add(data)` | `Effect<Id>` | `data: typeof Model.insert.Type`. Firestore picks the id. |
| `set(id, { data, variant?, merge? })` | `Effect<void>` | Upsert at a known id. See "Choosing `set` variant" below. |
| `update(id, partial)` | `Effect<void>` | Fails `FirestoreError` code `not-found` if absent. Accepts sentinels. |
| `getById(id)` | `Effect<Option<Model>>` | `Option.none()` when missing. |
| `getByIdStream(id)` | `Stream<Option<Model>>` | Live `onSnapshot`. |
| `delete(id)` | `Effect<void>` | |
| `deleteRecursive(id)` | `Effect<void>` | **Admin SDK only**; dies on the client layer. |
| `query(constraints)` | `Effect<ReadonlyArray<Model>>` | |
| `queryStream(constraints)` | `Stream<ReadonlyArray<Model>>` | Live. |
| `getByQuery(constraints)` | `Effect<Option<Model>>` | First match. |
| `getByQueryStream(constraints)` | `Stream<Option<Model>>` | Live first match. |
| Method | Returns | Notes |
| ------------------------------------- | ------------------------------ | ----------------------------------------------------------------------- |
| `add(data)` | `Effect<Id>` | `data: typeof Model.insert.Type`. Firestore picks the id. |
| `set(id, { data, variant?, merge? })` | `Effect<void>` | Upsert at a known id. See "Choosing `set` variant" below. |
| `update(id, data)` | `Effect<void>` | Fails `not-found` if absent. Sentinels + dotted field paths. See below. |
| `getById(id)` | `Effect<Option<Model>>` | `Option.none()` when missing. |
| `getByIdStream(id)` | `Stream<Option<Model>>` | Live `onSnapshot`. |
| `delete(id)` | `Effect<void>` | |
| `deleteRecursive(id)` | `Effect<void>` | **Admin SDK only**; dies on the client layer. |
| `query(constraints)` | `Effect<ReadonlyArray<Model>>` | |
| `queryStream(constraints)` | `Stream<ReadonlyArray<Model>>` | Live. |
| `getByQuery(constraints)` | `Effect<Option<Model>>` | First match. |
| `getByQueryStream(constraints)` | `Stream<Option<Model>>` | Live first match. |

### Choosing `set` variant

Expand All @@ -161,6 +161,43 @@ To create-only at a known id without clobbering, read then write inside
`Firestore.withTransaction`; a bare `getById` then `set` is a race. Prefer
`add` (always insert) or `update` (always update) when the intent is fixed.

### Updating nested fields

`update` takes any subset of `Model.update.Type` plus Firestore dotted field
paths into nested maps. A dotted key touches only that nested field; a
whole-field key replaces the whole map (Firestore semantics).

```ts
yield * repo.update(id, { 'metaData.deleted': true }); // only metaData.deleted
yield * repo.update(id, { metaData: { deleted: true, tags: [] } }); // replaces metaData
yield * repo.update(id, { metaData: { deleted: true } }, { merge: true }); // only metaData.deleted
```

`{ merge: true }` takes a deep partial and flattens nested objects into dotted
paths before the write, like Firestore's `set(..., { merge: true })`. Arrays,
class instances (`DateTime`, sentinels…) and `Option.none()` are leaves and
are written whole; `Option.some({ ... })` is merged into. An empty object
contributes nothing (writing an empty map would clobber the existing one).

Paths are typed (`UpdateData<T>`) and descend through `Schema.Struct`,
`Model.Struct`, `Schema.Class`, `Schema.Record` (keys checked against the key
schema), `Schema.suspend`, `Schema.optional` and `OptionalDeletable`; arrays,
`DateTime`, `Timestamp`, `GeoPoint`, `Reference` and sentinel classes are
leaves. Each leaf is encoded through its own field schema, so a nested
`Firestore.Number` accepts `increment(n)` at `'stats.likes'`.

Depth is capped at `Firestore.MAX_FIELD_PATH_DEPTH` (5 levels below a
top-level field) at both the type and runtime level; deeper writes go through
`FirestoreService.update`. Recursive schemas work: declare the recursive type
as a type alias to get typed paths into it (an interface is a leaf at the type
level; the runtime resolves either).

Keys the model does not declare (typos, paths into scalars) fail with
`SchemaError` naming the key; they are never dropped. An empty payload fails
with `FirestoreError` code `invalid-argument` instead of the SDK's "At least
one field must be updated". `add` and `set` reject undeclared keys the same
way.

## Queries

```ts
Expand Down Expand Up @@ -406,6 +443,10 @@ root: https://github.com/fwal/effect-firebase/blob/main/REACT.md.
variants that helper allows.
9. Style used throughout the library: explicit lambdas (`Effect.map((x) => f(x))`),
no point-free `Effect.map(f)`.
10. Nested updates use dotted keys (`'a.b': v`) or `{ merge: true }` with a
nested partial; a plain `{ a: { b: v } }` replaces the whole `a` map.
Undeclared keys fail with `SchemaError`; `update` never silently drops
them.

## Where to look

Expand Down
16 changes: 16 additions & 0 deletions packages/effect-firebase/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,22 @@ encoder, replace it with `repo.set` and choose the `variant` deliberately —
`'insert'` re-stamps `DateTimeInsert` fields on every write, `'update'` with
`merge: true` preserves them.

**Undeclared keys are rejected.** `add`, `set` and `update` used to strip any
key the model does not declare before writing; a `repo.update(id, { 'a.b': 1 })`
degenerated into an empty write that Firestore rejected with "At least one
field must be updated". They now fail with a `SchemaError` naming the key,
and an empty `update` payload fails with `FirestoreError` code
`invalid-argument`. If a call site relied on extra keys being dropped, remove
them from the payload.

**Nested updates use dotted paths.** `update` accepts Firestore field paths
typed against the model (`'metaData.deleted': true`) and encodes each leaf
through its own field schema, so nested sentinels work. Replace hand-rolled
`FirestoreService.update(path, { 'a.b': v })` calls with `repo.update(id,
{ 'a.b': v })`. Note that a whole-field key (`metaData: { ... }`) still
replaces the entire map; pass `{ merge: true }` as a third argument to have a
nested partial flattened into dotted paths instead.

### 8. `FirestoreService` shape changes (custom layers and test doubles)

Only relevant if you implement `FirestoreService` yourself or pass overrides
Expand Down
5 changes: 5 additions & 0 deletions packages/effect-firebase/src/lib/firestore/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export * from './model/number.js';

// Repository factory
export { makeRepository } from './model/repository.js';
export {
MAX_FIELD_PATH_DEPTH,
type MergeUpdateData,
type UpdateData,
} from './model/update-path.js';

// Transaction and batch helpers
export { withTransaction, withBatch } from './transaction.js';
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,7 @@ describe('Model.WithServerTimestamp', () => {
const encode = Schema.encodeSync(TestModel.insert);
const result = encode({ lastSeenAt: serverTimestamp() });

expect(result.lastSeenAt).toBeInstanceOf(
FirestoreSchema.ServerTimestamp,
);
expect(result.lastSeenAt).toBeInstanceOf(FirestoreSchema.ServerTimestamp);
});
});

Expand All @@ -219,9 +217,7 @@ describe('Model.WithServerTimestamp', () => {
const encode = Schema.encodeSync(TestModel.update);
const result = encode({ lastSeenAt: serverTimestamp() });

expect(result.lastSeenAt).toBeInstanceOf(
FirestoreSchema.ServerTimestamp,
);
expect(result.lastSeenAt).toBeInstanceOf(FirestoreSchema.ServerTimestamp);
});
});

Expand Down
24 changes: 18 additions & 6 deletions packages/effect-firebase/src/lib/firestore/model/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { Array as Arr, Cause, Effect, Option, Schema, Stream } from 'effect';
import type { SchemaAST } from 'effect';

/**
* Request payloads are encoded strictly: a key the request schema does not
* declare fails with a `SchemaError` naming the key instead of being
* silently dropped. Without this, an undeclared key (a typo, or a dotted
* field path handed to a struct schema) vanishes before the write reaches
* Firestore, which then rejects the empty payload with an unrelated error.
*/
export const strictEncoding: SchemaAST.ParseOptions = {
onExcessProperty: 'error',
};

/**
* Find all records in the collection.
Comment thread
fwal marked this conversation as resolved.
Expand All @@ -15,7 +27,7 @@ export const findAll = <
request: Req['Encoded'],
) => Effect.Effect<ReadonlyArray<unknown>, E, R>;
}) => {
const encodeRequest = Schema.encodeEffect(options.Request);
const encodeRequest = Schema.encodeEffect(options.Request, strictEncoding);
const decode = Schema.decodeUnknownEffect(
Schema.mutable(Schema.Array(options.Result)),
);
Expand Down Expand Up @@ -69,7 +81,7 @@ const _void = <Req extends Schema.Top, E, R>(options: {
readonly Request: Req;
readonly execute: (request: Req['Encoded']) => Effect.Effect<unknown, E, R>;
}) => {
const encode = Schema.encodeEffect(options.Request);
const encode = Schema.encodeEffect(options.Request, strictEncoding);
return (
request: Req['Type'],
): Effect.Effect<void, E | Schema.SchemaError, R | Req['EncodingServices']> =>
Expand All @@ -93,7 +105,7 @@ export const findOneOption = <
request: Req['Encoded'],
) => Effect.Effect<ReadonlyArray<unknown>, E, R>;
}) => {
const encodeRequest = Schema.encodeEffect(options.Request);
const encodeRequest = Schema.encodeEffect(options.Request, strictEncoding);
const decode = Schema.decodeUnknownEffect(options.Result);
return (
request: Req['Type'],
Expand Down Expand Up @@ -132,7 +144,7 @@ export const findOne = <
request: Req['Encoded'],
) => Effect.Effect<ReadonlyArray<unknown>, E, R>;
}) => {
const encodeRequest = Schema.encodeEffect(options.Request);
const encodeRequest = Schema.encodeEffect(options.Request, strictEncoding);
const decode = Schema.decodeUnknownEffect(options.Result);
return (
request: Req['Type'],
Expand Down Expand Up @@ -171,7 +183,7 @@ export const streamOne = <
request: Req['Encoded'],
) => Stream.Stream<Option.Option<unknown>, E, R>;
}) => {
const encodeRequest = Schema.encodeEffect(options.Request);
const encodeRequest = Schema.encodeEffect(options.Request, strictEncoding);
const decode = Schema.decodeUnknownEffect(options.Result);
return (
request: Req['Type'],
Expand Down Expand Up @@ -207,7 +219,7 @@ export const streamAll = <
request: Req['Encoded'],
) => Stream.Stream<ReadonlyArray<unknown>, E, R>;
}) => {
const encodeRequest = Schema.encodeEffect(options.Request);
const encodeRequest = Schema.encodeEffect(options.Request, strictEncoding);
const decode = Schema.decodeUnknownEffect(
Schema.mutable(Schema.Array(options.Result)),
);
Expand Down
Loading
Loading