diff --git a/.changeset/collection-keys.md b/.changeset/collection-keys.md new file mode 100644 index 0000000..9201625 --- /dev/null +++ b/.changeset/collection-keys.md @@ -0,0 +1,5 @@ +--- +"@visiolab/effect-firebase": minor +--- + +A model can hold a typed `FirestoreSchema.DocumentReference` into its own collection, into a subcollection of it, or into a collection whose model references it back. Name the target with a `Collection.Key` (`interface UsersKey extends Collection.Key {}`) and write the field as `Schema.suspend((): FirestoreSchema.DocumentReference => FirestoreSchema.DocumentReference(Users))`. The reference reads back as `Collection.DocumentReference`. Fields that close no cycle are unchanged. diff --git a/GLOSSARY.md b/GLOSSARY.md index e9445ae..d1565ca 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -34,6 +34,10 @@ A **Transaction** that can also read every document in a collection or a **Colle The collections the model defines under one collection id, wherever each sits, read together as one. Each document read through it still belongs to the one collection it lives in. _Avoid_: group query, all collections named X +**Collection Key**: +A name for one collection the model defines that a model's own fields can use before that collection exists, such as a user's reference to another user. A **Document Reference** typed through it is the same as one typed by the collection. +_Avoid_: collection type, collection handle, collection ref + ### Reads **Document Snapshot**: diff --git a/docs/adr/0004-cyclic-references-go-through-collection-keys.md b/docs/adr/0004-cyclic-references-go-through-collection-keys.md new file mode 100644 index 0000000..06e27ef --- /dev/null +++ b/docs/adr/0004-cyclic-references-go-through-collection-keys.md @@ -0,0 +1,14 @@ +# Cyclic references go through Collection Keys + +A model cannot hold a `FirestoreSchema.DocumentReference` into a collection built from itself, such as a user's `bestFriend` or a user's reference to one of their own posts. Typing that field needs the collection's type, which contains `typeof User`, while TypeScript is still computing `typeof User` from the class's base expression (TS2310/TS2506). `typeof User` is only safe there when TypeScript reads it lazily, and a collection constant's type can only be inferred from `Collection.make(…, User)`, so it is only safe one step further away: as the member of an interface that nothing opens until the reference is used. We therefore name such a target with a **Collection Key**, `interface UsersKey extends Collection.Key {}`, and `FirestoreSchema.DocumentReference` accepts either a collection or a key, resolving the key to the same `Collection.DocumentReference` a collection would give. The cyclic field is written the way Effect writes recursive schemas, with an annotated `Schema.suspend((): FirestoreSchema.DocumentReference => FirestoreSchema.DocumentReference(Users))`. The body of that function is checked after the class exists, so a key that does not match the collection is a type error. + +Acyclic fields keep `FirestoreSchema.DocumentReference(Users)` with no annotation. + +## Considered Options + +- **A type alias spelling out every level** (`Collection.Collection`). It works today without library changes, but it copies the whole chain of paths and models by hand at every referenced collection. +- **Naming the constant directly**, as `type UsersC = typeof Users` or `interface UsersRef extends FirestoreSchema.DocumentReference {}`. Both are circular: `Schema.suspend` opens the interface right away, and the constant's type needs `User` finished. +- **A global registry from path strings to collections** through module augmentation, as TanStack Router's `Register` does. Rejected because there is one per program, it adds a second name for every collection that can disagree with the definition, and it needs registration boilerplate apart from the model. +- **A reference typed by path and the target's model** (the typesaurus shape, `ref("users/posts", …)`). Rejected because the path has to be repeated as an argument, and a self-reference still needs the model's own `typeof`. +- **A marker on the model class pointing to its collection** (`declare readonly "~collection": typeof Users` in the class body). It type-checks, but it ties a model to a single collection, while a model must be able to back several, such as `users` and `archivedUsers`. It also puts a type-only member on every document and makes the model know where it is stored. +- **A collection type that drops its ancestors' models**, so a subcollection's type no longer contains its parent's model. That fixes references from a parent into its own subcollection, but not self-references or mutual cycles, and it is a breaking change to `Collection`'s type parameter. diff --git a/packages/effect-firebase/src/firestore/Collection.ts b/packages/effect-firebase/src/firestore/Collection.ts index 62d4449..4cb4d43 100644 --- a/packages/effect-firebase/src/firestore/Collection.ts +++ b/packages/effect-firebase/src/firestore/Collection.ts @@ -287,6 +287,62 @@ export interface Collection extends Inspectable.Inspectable { */ export type Any = Collection; +/** + * A name for a collection that a model's own fields can use before the + * collection is defined. + * + * **When to use** + * + * Use only for a {@link FirestoreSchema.DocumentReference} field that closes a + * cycle: a model referencing its own collection, a subcollection of it, or a + * collection whose model references it back. Extend it once per such + * collection, as `interface UsersKey extends Collection.Key {}`. + * Every other reference field names the collection directly. + * + * **Details** + * + * A model's fields cannot name `typeof Users` while `Users` is still being + * built from the model. An interface that extends this one only holds the + * collection in a member, which TypeScript reads once the reference is used, + * so the key can be named inside the model. A + * {@link FirestoreSchema.DocumentReference} typed by the key reads back as + * the same {@link DocumentReference} the collection gives. + * + * A model that backs several collections, such as `users` and + * `archivedUsers`, needs one key per collection, and references through them + * stay distinct types. + * + * @example Referencing a document of the model's own collection + * ```ts + * import { Schema } from "effect"; + * import { Collection, FirestoreModel, FirestoreSchema } from "@visiolab/effect-firebase/firestore"; + * + * interface UsersKey extends Collection.Key {} + * + * class User extends FirestoreModel.Class("User")({ + * name: Schema.String, + * bestFriend: Schema.NullOr( + * Schema.suspend( + * (): FirestoreSchema.DocumentReference => FirestoreSchema.DocumentReference(Users), + * ), + * ), + * }) {} + * const Users = Collection.make("users", User); + * ``` + * + * @see {@link FirestoreSchema.DocumentReference} for the schema typed by a key or a collection + * + * @stability unstable + * @category models + * @since 0.4.0 + */ +export interface Key { + // Held in a member, not checked against `Collection`, so naming a key does + // not ask for the collection's type until a reference through it is used. + /** The collection this key names. Only read at the type level. */ + readonly "~collection": C; +} + /** * The id of a document in `C`, branded with `C`'s path. * diff --git a/packages/effect-firebase/src/firestore/FirestoreSchema.ts b/packages/effect-firebase/src/firestore/FirestoreSchema.ts index 4c570fc..ef4f42e 100644 --- a/packages/effect-firebase/src/firestore/FirestoreSchema.ts +++ b/packages/effect-firebase/src/firestore/FirestoreSchema.ts @@ -176,16 +176,36 @@ export const DocumentPath: DocumentPath = firestoreSchema.DocumentPath; /** * Type-level representation of {@link DocumentReference}. * + * **Details** + * + * `C` is the collection the reference points into, or a {@link Collection.Key} + * naming it. Either way the reference reads back as the same + * {@link Collection.DocumentReference}. Name a key only to annotate a + * `Schema.suspend` field that closes a cycle; see {@link DocumentReference}. + * + * @see {@link Collection.Key} for naming a collection before it is defined + * * @stability unstable * @category models * @since 0.2.0 */ -export interface DocumentReference extends Schema.declare< - Collection.DocumentReference -> { +export interface DocumentReference< + C extends Collection.Any | Collection.Key, +> extends Schema.declare> { readonly Rebuild: DocumentReference; } +// Resolving the key directly in `Schema.declare<...>` is circular for a +// cyclic model, so it goes through one more interface. For a collection it is +// identical to `Collection.DocumentReference`. +interface ResolvedReference extends Collection.DocumentReference> {} + +type CollectionOf = C extends { readonly "~collection": infer K extends Collection.Any } + ? K + : C extends Collection.Any + ? C + : never; + /** * Schema for a reference to a document in `collection`, held as a typed * {@link Collection.DocumentReference}. @@ -197,6 +217,11 @@ export interface DocumentReference extends Schema.decl * several collections, use a `Schema.Union` of one of these per collection. * Use {@link DocumentPath} when the target collection is unknown. * + * When the field closes a cycle, because the collection is built from this + * model, from a subcollection of it, or from a model that references this one + * back, wrap it in `Schema.suspend` and annotate the thunk's return type with + * a {@link Collection.Key}. Leave every other field unannotated. + * * **Details** * * Firestore stores a reference as the document's path alone, so it is written @@ -219,7 +244,26 @@ export interface DocumentReference extends Schema.decl * }) {} * ``` * + * @example Referencing a document of the model's own collection + * ```ts + * import { Schema } from "effect"; + * import { Collection, FirestoreModel, FirestoreSchema } from "@visiolab/effect-firebase/firestore"; + * + * interface UsersKey extends Collection.Key {} + * + * class User extends FirestoreModel.Class("User")({ + * name: Schema.String, + * bestFriend: Schema.NullOr( + * Schema.suspend( + * (): FirestoreSchema.DocumentReference => FirestoreSchema.DocumentReference(Users), + * ), + * ), + * }) {} + * const Users = Collection.make("users", User); + * ``` + * * @see {@link DocumentPath} for an untyped reference + * @see {@link Collection.Key} for a field that references the model's own collection, or closes another cycle * * @stability unstable * @category schemas diff --git a/packages/effect-firebase/src/firestore/internal/firestoreSchema.ts b/packages/effect-firebase/src/firestore/internal/firestoreSchema.ts index 6537680..3aeaa24 100644 --- a/packages/effect-firebase/src/firestore/internal/firestoreSchema.ts +++ b/packages/effect-firebase/src/firestore/internal/firestoreSchema.ts @@ -49,7 +49,9 @@ export const DocumentReference = ( collection: C, ): FirestoreSchema.DocumentReference => { const pattern = collection_.pattern(collection); - return Schema.declare>( + // The public type resolves a Collection Key to its collection, which the + // compiler cannot follow for a generic `C`; for a collection it is the same. + const schema = Schema.declare>( (u): u is Collection.DocumentReference => collection_.isDocumentReference(u) && u.collection === collection, { @@ -84,6 +86,7 @@ export const DocumentReference = ( ), }, ); + return schema as unknown as FirestoreSchema.DocumentReference; }; /** @internal */ diff --git a/packages/effect-firebase/tests/firestore/FirestoreSchema.test.ts b/packages/effect-firebase/tests/firestore/FirestoreSchema.test.ts index bd7ec34..666aa59 100644 --- a/packages/effect-firebase/tests/firestore/FirestoreSchema.test.ts +++ b/packages/effect-firebase/tests/firestore/FirestoreSchema.test.ts @@ -222,6 +222,45 @@ describe("a model of the sentinels", () => { ); }); +describe("DocumentReference through a Collection Key", () => { + interface PeopleKey extends Collection.Key {} + interface DiariesKey extends Collection.Key {} + + class Person extends FirestoreModel.Class("Person")({ + name: Schema.String, + bestFriend: Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(People), + ), + pinned: Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Diaries), + ), + }) {} + class Diary extends FirestoreModel.Class("Diary")({ title: Schema.String }) {} + const People = Collection.make("people", Person); + const Diaries = People.subcollection("diaries", Diary); + + it.effect("is stored as the documents' paths, and reads back equal", () => + Effect.gen(function* () { + const alice = People.id.make("alice"); + const bob = People.doc(People.id.make("bob")); + const monday = Diaries.doc(alice, Diaries.id.make("monday")); + const encoded = yield* toFirestore(Person)( + new Person({ name: "Alice", bestFriend: bob, pinned: monday }), + ); + expect(encoded).toStrictEqual({ + name: "Alice", + bestFriend: DocumentPath.makeUnsafe("people/bob"), + pinned: DocumentPath.makeUnsafe("people/alice/diaries/monday"), + }); + const decoded = yield* fromFirestore(Person)(encoded); + expect(decoded.bestFriend.collection).toBe(People); + expect(Equal.equals(decoded.bestFriend, bob)).toBe(true); + expect(decoded.pinned.collection).toBe(Diaries); + expect(Equal.equals(decoded.pinned, monday)).toBe(true); + }), + ); +}); + describe("ServerTimestamp", () => { it.effect("is stored as the portable sentinel, with no layer", () => Effect.gen(function* () { diff --git a/packages/effect-firebase/typetest/FirestoreSchema.tst.ts b/packages/effect-firebase/typetest/FirestoreSchema.tst.ts index 8d9fdbf..733cd49 100644 --- a/packages/effect-firebase/typetest/FirestoreSchema.tst.ts +++ b/packages/effect-firebase/typetest/FirestoreSchema.tst.ts @@ -1,10 +1,12 @@ import * as Collection from "@visiolab/effect-firebase/firestore/Collection"; +import type * as DocumentSnapshot from "@visiolab/effect-firebase/firestore/DocumentSnapshot"; import type * as FieldValue from "@visiolab/effect-firebase/firestore/FieldValue"; +import type * as Firestore from "@visiolab/effect-firebase/firestore/Firestore"; import * as FirestoreModel from "@visiolab/effect-firebase/firestore/FirestoreModel"; import * as FirestoreSchema from "@visiolab/effect-firebase/firestore/FirestoreSchema"; import type * as GeoPoint from "@visiolab/effect-firebase/firestore/GeoPoint"; import type * as Timestamp from "@visiolab/effect-firebase/firestore/Timestamp"; -import { Schema } from "effect"; +import { type Effect, Schema } from "effect"; import { describe, expect, test } from "tstyche"; describe("Timestamp", () => { @@ -79,6 +81,219 @@ describe("DocumentReference", () => { }); }); +declare const firestore: Firestore.Firestore; + +interface FriendsKey extends Collection.Key {} + +class Friend extends FirestoreModel.Class("Friend")({ + name: Schema.String, + bestFriend: Schema.NullOr( + Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Friends), + ), + ), +}) {} +const Friends = Collection.make("friends", Friend); + +declare const friend: Friend; + +interface NotesKey extends Collection.Key {} + +class Author extends FirestoreModel.Class("Author")({ + name: Schema.String, + pinned: Schema.NullOr( + Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Notes), + ), + ), +}) {} +class Note extends FirestoreModel.Class("Note")({ title: Schema.String }) {} +const Authors = Collection.make("authors", Author); +const Notes = Authors.subcollection("notes", Note); + +declare const author: Author; + +interface WritersKey extends Collection.Key {} +interface EssaysKey extends Collection.Key {} + +class Writer extends FirestoreModel.Class("Writer")({ + name: Schema.String, + featured: Schema.NullOr( + Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Essays), + ), + ), +}) {} +class Essay extends FirestoreModel.Class("Essay")({ + title: Schema.String, + writer: Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Writers), + ), +}) {} +const Writers = Collection.make("writers", Writer); +const Essays = Collection.make("essays", Essay); + +declare const writer: Writer; +declare const essay: Essay; + +interface ProfilesKey extends Collection.Key {} +interface ArchivedProfilesKey extends Collection.Key {} + +class Profile extends FirestoreModel.Class("Profile")({ + name: Schema.String, + successor: Schema.NullOr( + Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(Profiles), + ), + ), + predecessor: Schema.NullOr( + Schema.suspend((): FirestoreSchema.DocumentReference => + FirestoreSchema.DocumentReference(ArchivedProfiles), + ), + ), +}) {} +const Profiles = Collection.make("profiles", Profile); +const ArchivedProfiles = Collection.make("archivedProfiles", Profile); + +declare const profile: Profile; + +describe("DocumentReference through a Collection Key", () => { + describe("into the model's own collection", () => { + test("reads back as a reference typed by the collection", () => { + expect(friend.bestFriend).type.toBe | null>(); + }); + + test("reads the document through the collection's model", () => { + if (friend.bestFriend !== null) { + expect(firestore.getDoc(friend.bestFriend)).type.toBe< + Effect.Effect, Firestore.FirestoreError> + >(); + } + }); + + test("updates the document through the collection's model", () => { + if (friend.bestFriend !== null) { + expect(firestore.updateDoc(friend.bestFriend, { name: "Bob" })).type.toBe< + Effect.Effect + >(); + // @ts-expect-error Type 'number' is not assignable to type 'string' + void firestore.updateDoc(friend.bestFriend, { name: 1 }); + } + }); + }); + + describe("into a subcollection of the model's own collection", () => { + test("reads back as a reference typed by the subcollection", () => { + expect(author.pinned).type.toBe | null>(); + }); + + test("reads the document through the subcollection's model", () => { + if (author.pinned !== null) { + expect(firestore.getDoc(author.pinned)).type.toBe< + Effect.Effect, Firestore.FirestoreError> + >(); + } + }); + + test("updates the document through the subcollection's model", () => { + if (author.pinned !== null) { + expect(firestore.updateDoc(author.pinned, { title: "Hi" })).type.toBe< + Effect.Effect + >(); + // @ts-expect-error 'name' does not exist in type + void firestore.updateDoc(author.pinned, { name: "Bob" }); + } + }); + }); + + describe("between two models that reference each other", () => { + test("reads back as references typed by each other's collection", () => { + expect(writer.featured).type.toBe | null>(); + expect(essay.writer).type.toBe>(); + }); + + test("reads each document through its collection's model", () => { + expect(firestore.getDoc(essay.writer)).type.toBe< + Effect.Effect, Firestore.FirestoreError> + >(); + if (writer.featured !== null) { + expect(firestore.getDoc(writer.featured)).type.toBe< + Effect.Effect, Firestore.FirestoreError> + >(); + } + }); + + test("updates each document through its collection's model", () => { + expect(firestore.updateDoc(essay.writer, { name: "Ann" })).type.toBe< + Effect.Effect + >(); + // @ts-expect-error 'title' does not exist in type + void firestore.updateDoc(essay.writer, { title: "Hi" }); + if (writer.featured !== null) { + expect(firestore.updateDoc(writer.featured, { title: "Hi" })).type.toBe< + Effect.Effect + >(); + // @ts-expect-error 'name' does not exist in type + void firestore.updateDoc(writer.featured, { name: "Ann" }); + } + }); + }); + + describe("into one of two collections backed by the same model", () => { + test("reads back as a reference typed by the collection its key names", () => { + expect(profile.successor).type.toBe | null>(); + expect(profile.predecessor).type.toBe | null>(); + }); + + test("is not a reference into the other collection", () => { + expect(profile.predecessor).type.not.toBeAssignableTo | null>(); + expect(profile.successor).type.not.toBeAssignableTo | null>(); + }); + + test("reads and updates the document through the shared model", () => { + if (profile.predecessor !== null) { + expect(firestore.getDoc(profile.predecessor)).type.toBe< + Effect.Effect< + DocumentSnapshot.DocumentSnapshot, + Firestore.FirestoreError + > + >(); + expect(firestore.updateDoc(profile.predecessor, { name: "Ann" })).type.toBe< + Effect.Effect + >(); + // @ts-expect-error Type 'number' is not assignable to type 'string' + void firestore.updateDoc(profile.predecessor, { name: 1 }); + } + }); + }); + + test("refuses a key that names another collection than the one referenced", () => { + const wrong = (): FirestoreSchema.DocumentReference => + // @ts-expect-error is not assignable to type 'DocumentReference' + FirestoreSchema.DocumentReference(Notes); + void wrong; + }); + + test("refuses a key that names another collection backed by the same model", () => { + const wrong = (): FirestoreSchema.DocumentReference => + // @ts-expect-error is not assignable to type 'DocumentReference' + FirestoreSchema.DocumentReference(ArchivedProfiles); + void wrong; + }); + + test("is typed exactly as a reference into the collection when given one", () => { + expect["Type"]>().type.toBe< + Collection.DocumentReference + >(); + }); +}); + const Members = Collection.make("members", Member); class Venue extends FirestoreModel.Class("Venue")({