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
5 changes: 5 additions & 0 deletions .changeset/collection-keys.md
Original file line number Diff line number Diff line change
@@ -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<typeof Users> {}`) and write the field as `Schema.suspend((): FirestoreSchema.DocumentReference<UsersKey> => FirestoreSchema.DocumentReference(Users))`. The reference reads back as `Collection.DocumentReference<typeof Users>`. Fields that close no cycle are unchanged.
4 changes: 4 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
14 changes: 14 additions & 0 deletions docs/adr/0004-cyclic-references-go-through-collection-keys.md
Original file line number Diff line number Diff line change
@@ -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<typeof Users> {}`, and `FirestoreSchema.DocumentReference<X>` accepts either a collection or a key, resolving the key to the same `Collection.DocumentReference<typeof Users>` a collection would give. The cyclic field is written the way Effect writes recursive schemas, with an annotated `Schema.suspend((): FirestoreSchema.DocumentReference<UsersKey> => 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<readonly [readonly ["users", typeof User], readonly ["posts", typeof Post]]>`). 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<typeof Users> {}`. 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<Post>("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.
56 changes: 56 additions & 0 deletions packages/effect-firebase/src/firestore/Collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,62 @@ export interface Collection<L extends Levels> extends Inspectable.Inspectable {
*/
export type Any = Collection<any>;

/**
* 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<typeof Users> {}`.
* 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<typeof Users> {}
*
* class User extends FirestoreModel.Class<User>("User")({
* name: Schema.String,
* bestFriend: Schema.NullOr(
* Schema.suspend(
* (): FirestoreSchema.DocumentReference<UsersKey> => 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<C extends Any> {
// 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.
*
Expand Down
50 changes: 47 additions & 3 deletions packages/effect-firebase/src/firestore/FirestoreSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<C extends Collection.Any> extends Schema.declare<
Collection.DocumentReference<C>
> {
export interface DocumentReference<
C extends Collection.Any | Collection.Key<any>,
> extends Schema.declare<ResolvedReference<C>> {
readonly Rebuild: DocumentReference<C>;
}

// 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<C>`.
interface ResolvedReference<C> extends Collection.DocumentReference<CollectionOf<C>> {}

type CollectionOf<C> = 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}.
Expand All @@ -197,6 +217,11 @@ export interface DocumentReference<C extends Collection.Any> 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
Expand All @@ -219,7 +244,26 @@ export interface DocumentReference<C extends Collection.Any> 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<typeof Users> {}
*
* class User extends FirestoreModel.Class<User>("User")({
* name: Schema.String,
* bestFriend: Schema.NullOr(
* Schema.suspend(
* (): FirestoreSchema.DocumentReference<UsersKey> => 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export const DocumentReference = <C extends Collection.Any>(
collection: C,
): FirestoreSchema.DocumentReference<C> => {
const pattern = collection_.pattern(collection);
return Schema.declare<Collection.DocumentReference<C>>(
// 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<Collection.DocumentReference<C>>(
(u): u is Collection.DocumentReference<C> =>
collection_.isDocumentReference(u) && u.collection === collection,
{
Expand Down Expand Up @@ -84,6 +86,7 @@ export const DocumentReference = <C extends Collection.Any>(
),
},
);
return schema as unknown as FirestoreSchema.DocumentReference<C>;
};

/** @internal */
Expand Down
39 changes: 39 additions & 0 deletions packages/effect-firebase/tests/firestore/FirestoreSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,45 @@ describe("a model of the sentinels", () => {
);
});

describe("DocumentReference through a Collection Key", () => {
interface PeopleKey extends Collection.Key<typeof People> {}
interface DiariesKey extends Collection.Key<typeof Diaries> {}

class Person extends FirestoreModel.Class<Person>("Person")({
name: Schema.String,
bestFriend: Schema.suspend((): FirestoreSchema.DocumentReference<PeopleKey> =>
FirestoreSchema.DocumentReference(People),
),
pinned: Schema.suspend((): FirestoreSchema.DocumentReference<DiariesKey> =>
FirestoreSchema.DocumentReference(Diaries),
),
}) {}
class Diary extends FirestoreModel.Class<Diary>("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* () {
Expand Down
Loading
Loading