diff --git a/README.md b/README.md index 97aa8e6..9d32ca5 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/packages/effect-firebase/AGENTS.md b/packages/effect-firebase/AGENTS.md index 37a4a1d..adf2719 100644 --- a/packages/effect-firebase/AGENTS.md +++ b/packages/effect-firebase/AGENTS.md @@ -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 @@ -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), ), @@ -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). diff --git a/packages/effect-firebase/src/lib/firestore/fields/array.ts b/packages/effect-firebase/src/lib/firestore/fields/array.ts index 5c6db3b..86819e2 100644 --- a/packages/effect-firebase/src/lib/firestore/fields/array.ts +++ b/packages/effect-firebase/src/lib/firestore/fields/array.ts @@ -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, { @@ -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[]) {} } diff --git a/packages/effect-firebase/src/lib/firestore/fields/increment.ts b/packages/effect-firebase/src/lib/firestore/fields/increment.ts index 7e2013b..9e37baf 100644 --- a/packages/effect-firebase/src/lib/firestore/fields/increment.ts +++ b/packages/effect-firebase/src/lib/firestore/fields/increment.ts @@ -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) {} } diff --git a/packages/effect-firebase/src/lib/firestore/firestore.ts b/packages/effect-firebase/src/lib/firestore/firestore.ts index ea47598..7c7588d 100644 --- a/packages/effect-firebase/src/lib/firestore/firestore.ts +++ b/packages/effect-firebase/src/lib/firestore/firestore.ts @@ -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'; diff --git a/packages/effect-firebase/src/lib/firestore/model/update-path.spec.ts b/packages/effect-firebase/src/lib/firestore/model/update-path.spec.ts index 4981fa5..743fa37 100644 --- a/packages/effect-firebase/src/lib/firestore/model/update-path.spec.ts +++ b/packages/effect-firebase/src/lib/firestore/model/update-path.spec.ts @@ -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')({ 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; @@ -22,7 +23,7 @@ const INode: Schema.Codec = Schema.Struct({ label: Schema.String, next: Schema.optionalKey(Schema.suspend((): Schema.Codec => 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 = Schema.Struct({ label: Schema.String, @@ -44,6 +45,17 @@ class Doc extends Model.Class('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; @@ -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>; 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 @@ -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>; + 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>; + 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); }); }); diff --git a/packages/effect-firebase/src/lib/firestore/model/update-path.ts b/packages/effect-firebase/src/lib/firestore/model/update-path.ts index 7efe017..a0016e6 100644 --- a/packages/effect-firebase/src/lib/firestore/model/update-path.ts +++ b/packages/effect-firebase/src/lib/firestore/model/update-path.ts @@ -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 @@ -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 = Partial & +export type UpdateData = FieldPathRecord; + +/** + * 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 = Partial & NestedUpdateFields; +/** The field names and dotted field paths of `T`. */ +export type FieldPaths = keyof FieldPathRecord & string; + +/** The type stored at field name or dotted path `P` of `T`. */ +export type FieldPathType = + P extends FieldPaths ? Exclude[P], undefined> : never; + type UnionToIntersection = ( U extends unknown ? (k: U) => void : never ) extends (k: infer I) => void @@ -54,18 +72,61 @@ type NestedUpdateFields = [D] extends [0] }[keyof T & string] >; -// Interfaces and class instances (DateTime, Option, Timestamp, sentinels…) -// lack an implicit index signature, so they fail `Record` -// 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 + | ((...args: never[]) => unknown) + | Date + | Uint8Array + | ReadonlyMap + | ReadonlySet + | 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 extends FieldPathLeaf + ? false + : V extends object + ? true + : false; + type ChildUpdateFields = V extends Option.Option ? ChildUpdateFields - : V extends Record + : IsMap extends true ? AddPrefixToKeys & NestedUpdateFields> : never; +// Methods on `Schema.Class` instances are not fields. type AddPrefixToKeys = { - [K in keyof T & string as `${Prefix}.${K}`]?: T[K]; + [ + K in keyof T & string as [Exclude] extends [ + (...args: never[]) => unknown, + ] + ? never + : `${Prefix}.${K}` + ]?: T[K]; }; /** @@ -160,7 +221,7 @@ export type MergeUpdateData = { type MergeValue = V extends Option.Option ? Option.Option> - : V extends Record + : IsMap extends true ? MergeUpdateData : V; diff --git a/packages/effect-firebase/src/lib/firestore/query/query.spec.ts b/packages/effect-firebase/src/lib/firestore/query/query.spec.ts index 9b4d269..46e97ee 100644 --- a/packages/effect-firebase/src/lib/firestore/query/query.spec.ts +++ b/packages/effect-firebase/src/lib/firestore/query/query.spec.ts @@ -1,9 +1,97 @@ -import { pipe } from 'effect'; +import { DateTime, Option, Schema, pipe } from 'effect'; +import { Model } from 'effect/unstable/schema'; import { describe, expect, it } from 'vitest'; +import * as FirestoreModel from '../model/datetime.js'; +import { OptionalDeletable } from '../model/optional.js'; +import { TimestampDateTimeUtc } from '../schema/timestamp.js'; import { Limit, OrderBy, StartAfter } from './constraints.js'; import * as Query from './query.js'; +class Inner extends Schema.Class('Inner')({ x: Schema.Number }) {} + +class PostModel extends Model.Class('PostModel')({ + id: Schema.String, + status: Schema.Literals(['draft', 'published']), + metaData: Schema.Struct({ + type: Schema.String, + counts: Schema.Struct({ likes: Schema.Number }), + }), + tags: Schema.Array(Schema.String), + createdAt: FirestoreModel.DateTimeInsert, + profile: OptionalDeletable( + Schema.Struct({ lastSeenAt: TimestampDateTimeUtc }), + ), + counters: Schema.Record(Schema.String, Schema.Number), + klass: Inner, +}) {} + describe('Query', () => { + describe('nested field paths (#18)', () => { + // Mirrors how `repo.query(...)` supplies the model: S is inferred from + // the contextual `Query` type, not from the arguments. + const post = (query: Query.Query) => query; + type P = typeof PostModel; + + it('accepts dotted paths into nested maps and types the value', () => { + const query = pipe( + post(Query.where('metaData.type', '==', 'post')), + Query.addWhere( + 'metaData.counts.likes', + '>=', + 10, + ), + Query.addWhere( + 'profile.lastSeenAt', + '<', + DateTime.makeUnsafe(0), + ), + Query.addWhere('counters.visits', '>', 1), + Query.addOrderBy( + 'metaData.counts.likes', + 'desc', + ), + ); + + expect(query.map((c) => (c as { field: string }).field)).toEqual([ + 'metaData.type', + 'metaData.counts.likes', + 'profile.lastSeenAt', + 'counters.visits', + 'metaData.counts.likes', + ]); + }); + + it('still accepts top-level fields with their own value types', () => { + const queries = [ + post(Query.where('status', '==', 'published')), + post(Query.where('klass.x', '==', 1)), + post(Query.where('profile', '==', Option.none())), + post(Query.orderBy('createdAt', 'desc')), + ]; + expect(queries).toHaveLength(4); + }); + + // One statement per case: inside a single array literal TypeScript lets + // sibling elements influence inference and masks some of these errors. + // prettier-ignore + it('rejects unknown paths, paths into leaves, and mistyped values', () => { + // @ts-expect-error nope is not a field of metaData + const unknownPath = post(Query.where('metaData.nope', '==', 'x')); + // @ts-expect-error arrays are leaves + const intoArray = post(Query.where('tags.0', '==', 'x')); + // @ts-expect-error DateTime is a leaf + const intoDateTime = post(Query.where('createdAt.epochMillis', '==', 0)); + // Value typing is exact when K is fixed. Under contextual inference K + // widens to the whole key union (pre-existing), so values are only + // checked against the union of all field types there. + // @ts-expect-error likes is a number + const wrongLeafType = Query.where('metaData.counts.likes', '==', 'ten'); + // @ts-expect-error status is a literal union + const wrongLiteral = Query.where('status', '==', 'archived'); + expect([unknownPath, intoArray, intoDateTime, wrongLeafType, wrongLiteral]).toHaveLength(5); + }); + }); + describe('orderByDocumentId', () => { it('emits an OrderBy on the __name__ sentinel field path', () => { const [constraint] = Query.orderByDocumentId(); diff --git a/packages/effect-firebase/src/lib/firestore/query/query.ts b/packages/effect-firebase/src/lib/firestore/query/query.ts index be81e29..da11645 100644 --- a/packages/effect-firebase/src/lib/firestore/query/query.ts +++ b/packages/effect-firebase/src/lib/firestore/query/query.ts @@ -13,27 +13,28 @@ import { Where, type WhereFilterOp, } from './constraints.js'; +import type { FieldPaths, FieldPathType } from '../model/update-path.js'; // ============================================================================ // Type-Safe Field Extraction // ============================================================================ /** - * Extract field keys from a Schema struct (Model). + * The queryable field names of a Schema (Model): its top-level keys plus + * dotted paths into nested maps (`'metaData.type'`), following the same + * rules and depth cap as `FieldPathRecord` / `UpdateData`. */ -export type FieldKeys = S extends { readonly fields: infer F } - ? keyof F & string +export type FieldKeys = S extends { readonly Type: infer T } + ? FieldPaths : never; /** - * Extract the type of a specific field from a Schema. + * The type stored at a field name or dotted path of a Schema. */ export type FieldType = S extends { readonly Type: infer T; } - ? K extends keyof T - ? T[K] - : never + ? FieldPathType : never; // ============================================================================ @@ -65,6 +66,9 @@ export const empty = (): Query => [] as Query; * // Type-safe: field must exist on the model, value must match field type * Query.where('status', '==', 'active') * + * // Nested maps are addressed with dotted paths + * Query.where('metaData.type', '==', 'post') + * * // Or let TypeScript infer from usage context * Query.where('status', '==', 'active') * ``` diff --git a/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts b/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts index b69f847..3080ba2 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/geopoint.ts @@ -3,10 +3,16 @@ import { Schema } from 'effect'; /** * Class representing a GeoPoint in Firestore. */ +/** Type-level brand so `GeoPoint` is matched nominally, not by field shape. */ +export const GeoPointTypeId: unique symbol = Symbol.for( + 'effect-firebase/GeoPoint', +); export class GeoPoint extends Schema.Class('GeoPoint')({ latitude: Schema.Number, longitude: Schema.Number, -}) {} +}) { + declare readonly [GeoPointTypeId]: typeof GeoPointTypeId; +} /** * Schema where GeoPoint class instance is both Type and Encoded. diff --git a/packages/effect-firebase/src/lib/firestore/schema/reference.spec.ts b/packages/effect-firebase/src/lib/firestore/schema/reference.spec.ts index 7e98e80..890652b 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/reference.spec.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/reference.spec.ts @@ -113,7 +113,7 @@ describe('ReferenceInstance', () => { it('should reject non-Reference objects', () => { expect(() => - Schema.decodeSync(ReferenceInstance)({ + Schema.decodeUnknownSync(ReferenceInstance)({ id: 'doc123', path: 'users/doc123', }), diff --git a/packages/effect-firebase/src/lib/firestore/schema/reference.ts b/packages/effect-firebase/src/lib/firestore/schema/reference.ts index 395d083..bbb0aef 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/reference.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/reference.ts @@ -17,6 +17,10 @@ interface ReferenceShape { readonly parent?: ReferenceShape; } +/** Type-level brand so `Reference` is matched nominally, not by field shape. */ +export const ReferenceTypeId: unique symbol = Symbol.for( + 'effect-firebase/Reference', +); /** * Class representing a DocumentReference in Firestore. */ @@ -40,6 +44,8 @@ export class Reference extends Schema.Class('Reference')( ), ), ) { + declare readonly [ReferenceTypeId]: typeof ReferenceTypeId; + static makeFromPath(path: string): Reference { const parts = path.split('/').filter(Boolean); diff --git a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts index c75ac24..6dd7ede 100644 --- a/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts +++ b/packages/effect-firebase/src/lib/firestore/schema/timestamp.ts @@ -3,10 +3,15 @@ import { DateTime, Effect, Schema, SchemaGetter, SchemaIssue } from 'effect'; /** * Class representing a Timestamp in Firestore. */ +/** Type-level brand so `Timestamp` is matched nominally, not by field shape. */ +export const TimestampTypeId: unique symbol = Symbol.for( + 'effect-firebase/Timestamp', +); export class Timestamp extends Schema.Class('Timestamp')({ seconds: Schema.Number, nanoseconds: Schema.Number, }) { + declare readonly [TimestampTypeId]: typeof TimestampTypeId; static fromDate(date: Date): Timestamp { return Timestamp.fromMillis(date.getTime()); }