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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ const program = Effect.gen(function* () {
status: 'draft',
});
const posts = yield* repo.query(Query.where('status', '==', 'published'));
const articles = yield* repo.query(
Query.where('metaData.type', '==', 'article'),
);
return { postId, posts };
}).pipe(
Effect.provide(PostRepository),
Expand Down
11 changes: 7 additions & 4 deletions packages/effect-firebase/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ leaves. Each leaf is encoded through its own field schema, so a nested

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).
`FirestoreService.update`. Recursive schemas (`Schema.suspend`) work up to the
cap. What counts as a leaf is the `FieldPathLeaf` union; any other object
type is treated as a map.

Keys the model does not declare (typos, paths into scalars) fail with
`SchemaError` naming the key; they are never dropped. An empty payload fails
Expand All @@ -208,6 +208,7 @@ repo.query(
Query.and(
Query.where('status', '==', 'published'),
Query.where('likes', '>=', 10),
Query.where('metaData.type', '==', 'post'), // dotted paths into nested maps
Query.orderBy('createdAt', 'desc'),
Query.limit(20),
),
Expand All @@ -227,7 +228,9 @@ repo.query(
Constructors: `where`, `orderBy`, `orderByDocumentId`, `limit`,
`limitToLast`, `startAt`, `startAfter`, `endAt`, `endBefore`, `and`, `or`,
`empty`, plus `add*` pipeable variants of each. Field names and operators are
checked against the model at compile time. Cursor values are encoded like
checked against the model at compile time; field names include dotted paths
into nested maps, following the same descent rules and depth cap as `update`
(see "Updating nested fields"). Cursor values are encoded like
document data, so `DateTime.Utc`, `FirestoreSchema.Timestamp`, strings and
numbers all work. Firestore has no offset pagination; use cursors (see the
React guide for a growing-limit live feed and a cursor-stack prev/next).
Expand Down
10 changes: 10 additions & 0 deletions packages/effect-firebase/src/lib/firestore/fields/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { Schema } from 'effect';
* Represents an arrayUnion operation. This will add elements to an array field.
* Only valid in the `update` variant — use `WithArraySentinels` to add support to a field.
*/
/** Type-level brand so `ArrayUnion` is matched nominally, not by field shape. */
export const ArrayUnionTypeId: unique symbol = Symbol.for(
'effect-firebase/ArrayUnion',
);
export class ArrayUnion {
declare readonly [ArrayUnionTypeId]: typeof ArrayUnionTypeId;
constructor(public readonly values: readonly unknown[]) {}
}
export const ArrayUnionInstance = Schema.instanceOf(ArrayUnion, {
Expand All @@ -20,7 +25,12 @@ export const ArrayUnionInstance = Schema.instanceOf(ArrayUnion, {
* Represents an arrayRemove operation. This will remove elements from an array field.
* Only valid in the `update` variant — use `WithArraySentinels` to add support to a field.
*/
/** Type-level brand so `ArrayRemove` is matched nominally, not by field shape. */
export const ArrayRemoveTypeId: unique symbol = Symbol.for(
'effect-firebase/ArrayRemove',
);
export class ArrayRemove {
declare readonly [ArrayRemoveTypeId]: typeof ArrayRemoveTypeId;
constructor(public readonly values: readonly unknown[]) {}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { Schema } from 'effect';
* Only valid in the `update` variant — use `WithIncrementField` to add
* support to a field.
*/
/** Type-level brand so `Increment` is matched nominally, not by field shape. */
export const IncrementTypeId: unique symbol = Symbol.for(
'effect-firebase/Increment',
);
export class Increment {
declare readonly [IncrementTypeId]: typeof IncrementTypeId;
constructor(public readonly operand: number) {}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/effect-firebase/src/lib/firestore/firestore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export * from './model/number.js';
export { makeRepository } from './model/repository.js';
export {
MAX_FIELD_PATH_DEPTH,
type FieldPathLeaf,
type FieldPathRecord,
type FieldPaths,
type FieldPathType,
type MergeUpdateData,
type UpdateData,
} from './model/update-path.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {
} from './update-path.js';
import { OptionalDeletable } from './optional.js';
import * as FirestoreNumber from './number.js';
import { TimestampDateTimeUtc } from '../schema/timestamp.js';

class Inner extends Schema.Class<Inner>('Inner')({ x: Schema.Number }) {}

// Recursive map declared the usual way (interface + suspend)
// Recursive map declared as an interface…
interface INode {
readonly label: string;
readonly next?: INode;
Expand All @@ -22,7 +23,7 @@ const INode: Schema.Codec<INode> = Schema.Struct({
label: Schema.String,
next: Schema.optionalKey(Schema.suspend((): Schema.Codec<INode> => INode)),
});
// …and as a type alias, which gets typed paths.
// …and as a type alias; both get typed paths.
type ANode = { readonly label: string; readonly next?: ANode };
const ANode: Schema.Codec<ANode> = Schema.Struct({
label: Schema.String,
Expand All @@ -44,6 +45,17 @@ class Doc extends Model.Class<Doc>('Doc')({
scalar: Schema.String,
inode: INode,
anode: ANode,
stamp: TimestampDateTimeUtc,
arr: Schema.Array(Schema.String),
// Same field shapes as GeoPoint / Reference / Increment / ArrayUnion.
geo: Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number }),
file: Schema.Struct({
id: Schema.String,
path: Schema.String,
size: Schema.Number,
}),
op: Schema.Struct({ operand: Schema.Number }),
vals: Schema.Struct({ values: Schema.Array(Schema.Unknown) }),
}) {}

const root = Doc.update;
Expand Down Expand Up @@ -108,12 +120,13 @@ describe('resolveFieldPath on recursive schemas', () => {
);
});

it('types paths into recursive type aliases up to the cap, and none into interfaces', () => {
it('types paths into recursive aliases and interfaces up to the cap', () => {
type U = UpdateData<Omit<typeof Doc.update.Type, 'id'>>;
const ok: U = {
'anode.next.label': 'x',
'anode.next.next.next.next.label': 'x', // depth 5
inode: { label: 'whole value only' },
'inode.next.label': 'x',
inode: { label: 'whole value' },
};
const tooDeep: U = {
// @ts-expect-error depth 6 exceeds MAX_FIELD_PATH_DEPTH
Expand All @@ -123,11 +136,38 @@ describe('resolveFieldPath on recursive schemas', () => {
// @ts-expect-error label is a string
'anode.next.label': 1,
};
const intoInterface: U = {
// @ts-expect-error interfaces are leaves at the type level
'inode.label': 'x',
expect([ok, tooDeep, wrongLeaf]).toBeDefined();
});

it('descends into Schema.Class instances but not into leaf classes', () => {
type U = UpdateData<Omit<typeof Doc.update.Type, 'id'>>;
const ok: U = { 'klass.x': 1 };
const intoDateTime: U = {
// @ts-expect-error Timestamp-backed values are leaves
'stamp.epochMillis': 1,
};
const intoIncrement: U = {
// @ts-expect-error sentinels are leaves
'variant.likes.operand': 1,
};
const intoArray: U = {
// @ts-expect-error arrays are leaves
'arr.0': 'x',
};
expect([ok, intoDateTime, intoIncrement, intoArray]).toBeDefined();
});

it('matches leaf classes nominally, so structs with the same shape are maps', () => {
type U = UpdateData<Omit<typeof Doc.update.Type, 'id'>>;
const ok: U = {
'geo.latitude': 1,
'file.path': 'a/b',
'op.operand': 2,
'vals.values': [],
};
expect([ok, tooDeep, wrongLeaf, intoInterface]).toBeDefined();
expect(ok).toBeDefined();
expect(resolves('geo.latitude')).toBe(true);
expect(resolves('file.path')).toBe(true);
});
});

Expand Down
87 changes: 74 additions & 13 deletions packages/effect-firebase/src/lib/firestore/model/update-path.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Option, Schema, SchemaAST } from 'effect';
import type { DateTime } from 'effect';
import type { ArrayRemove, ArrayUnion } from '../fields/array.js';
import type { Increment } from '../fields/increment.js';
import type { GeoPoint } from '../schema/geopoint.js';
import type { Reference } from '../schema/reference.js';
import type { Timestamp } from '../schema/timestamp.js';

/**
* How many map levels a dotted field path may descend below a top-level
Expand Down Expand Up @@ -27,16 +33,28 @@ export const MAX_FIELD_PATH_DEPTH = 5;
* Paths descend through plain object types (`Schema.Struct`, `Model.Struct`,
* `Schema.Class`, `Schema.Record`, `Schema.suspend`) and through `Option`
* (so an `OptionalDeletable` map is reachable even when currently absent),
* up to {@link MAX_FIELD_PATH_DEPTH} levels. They stop at arrays,
* `DateTime`, `Timestamp`, `GeoPoint`, `Reference` and sentinel classes,
* which are written whole. Recursive types declared as interfaces (the usual
* pattern for `Schema.suspend`) are leaves at the type level, since
* interfaces have no implicit index signature; declare them as type aliases
* to get typed paths into them.
* up to {@link MAX_FIELD_PATH_DEPTH} levels. They stop at
* {@link FieldPathLeaf} values (arrays, `DateTime`, `Timestamp`, `GeoPoint`,
* `Reference`, sentinel classes…), which are written whole.
*/
export type UpdateData<T> = Partial<T> &
export type UpdateData<T> = FieldPathRecord<T>;

/**
* Every addressable field of `T` as a key: its own keys plus dotted paths
* into nested maps (see {@link UpdateData} for what is descended), each
* mapped to the type found at that path. Shared by `update` payloads and by
* typed `Query.where`/`Query.orderBy` field names.
*/
export type FieldPathRecord<T> = Partial<T> &
NestedUpdateFields<T, typeof MAX_FIELD_PATH_DEPTH>;

/** The field names and dotted field paths of `T`. */
export type FieldPaths<T> = keyof FieldPathRecord<T> & string;

/** The type stored at field name or dotted path `P` of `T`. */
export type FieldPathType<T, P extends string> =
P extends FieldPaths<T> ? Exclude<FieldPathRecord<T>[P], undefined> : never;

type UnionToIntersection<U> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
Expand All @@ -54,18 +72,61 @@ type NestedUpdateFields<T, D extends number> = [D] extends [0]
}[keyof T & string]
>;

// Interfaces and class instances (DateTime, Option, Timestamp, sentinels…)
// lack an implicit index signature, so they fail `Record<string, unknown>`
// and are treated as leaves; struct types and records pass and are descended.
/**
* Value types a field path never descends into: they are stored as a single
* Firestore value (or are write sentinels), so `'createdAt.epochMillis'` is
* not a field. Everything else that is an object — `Schema.Struct`,
* `Model.Struct`, `Schema.Class` instances, records, recursive interfaces —
* is a map and is descended, matching {@link resolveFieldPath}.
*/
export type FieldPathLeaf =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined
| ReadonlyArray<unknown>
| ((...args: never[]) => unknown)
| Date
| Uint8Array
| ReadonlyMap<unknown, unknown>
| ReadonlySet<unknown>
| DateTime.DateTime
| Timestamp
| GeoPoint
| Reference
| Increment
| ArrayUnion
| ArrayRemove;
// `Delete` and `ServerTimestamp` have no fields, so their instance type is
// `{}` and listing them would make every object a leaf. Descending into an
// empty type yields no paths, which is the same outcome.

/** Whether `V` (a single member, not a union) is descended into. */
type IsMap<V> = V extends FieldPathLeaf
Comment thread
greptile-apps[bot] marked this conversation as resolved.
? false
: V extends object
? true
: false;

type ChildUpdateFields<K extends string, V, D extends number> =
V extends Option.Option<infer U>
? ChildUpdateFields<K, U, D>
: V extends Record<string, unknown>
: IsMap<V> extends true
? AddPrefixToKeys<K, Partial<V> & NestedUpdateFields<V, D>>
: never;

// Methods on `Schema.Class` instances are not fields.
type AddPrefixToKeys<Prefix extends string, T> = {
[K in keyof T & string as `${Prefix}.${K}`]?: T[K];
[
K in keyof T & string as [Exclude<T[K], undefined>] extends [
(...args: never[]) => unknown,
]
? never
: `${Prefix}.${K}`
]?: T[K];
};

/**
Expand Down Expand Up @@ -160,7 +221,7 @@ export type MergeUpdateData<T> = {
type MergeValue<V> =
V extends Option.Option<infer U>
? Option.Option<MergeValue<U>>
: V extends Record<string, unknown>
: IsMap<V> extends true
? MergeUpdateData<V>
: V;

Expand Down
Loading
Loading