Skip to content

Overhaul Documents #1886

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed

Overhaul Documents #1886

wants to merge 1 commit into from

Conversation

wilhuff
Copy link
Contributor

@wilhuff wilhuff commented Jun 15, 2019

Remove different internal types for MaybeDocument, Document, NoDocument,
and UnknownDocument. These are all now just Document.

Also make Documents non-nullable nearly everywhere.

The change introduces a DocumentType enum and maps the old classes like
so:

Before After
null DocumentType.UNKNOWN
UnknownDocument DocumentType.CONTENTS_UNKNOWN
NoDocument DocumentType.MISSING
Document DocumentType.EXISTING

Part of why this change works is that most of the system only cares
whether or not a document exists. We have several refinements of
non-existing documents for the purposes of caching but everywhere else
we can ignore it and just check document.exists. The instanceof checks
were usually over-constraining things.

The motivation for this change is in two parts:

  • It's always bugged me that we had so many instanceof checks around
  • In C++ we don't have instanceof anyway, so we have to do something
    else.

Also, this definitely addresses b/32275378.

@wilhuff
Copy link
Contributor Author

wilhuff commented Jun 15, 2019

Note that this PR is requesting feedback on the idea in here as well. I think this makes things considerably easier to reason about internally and it will be definitely make the C++ implementation easier (no subclasses make things so much easier), but I wanted to vet this outside the noise of actually porting iOS Objective-C code to C++.

Remove different internal types for MaybeDocument, Document, NoDocument,
and UnknownDocument. These are all now just Documents.

Also make Documents non-nullable nearly everywhere.

The change introduces a DocumentType enum and maps the old classes like
so:

  null =>             DocumentType.UNKNOWN
  UnknownDocument =>  DocumentType.CONTENTS_UNKNOWN
  NoDocument =>       DocumentType.MISSING
  Document =>         DocumentType.EXISTING

Part of why this change works is that most of the system only cares
whether or not a document exists. We have several refinements of
non-existing documents for the purposes of caching but everywhere else
we can ignore it and just check Document.exists. The instanceof checks
were usually overconstraining things.

The motivation for this change is in two parts:

  * It's always bugged me that we had so many instanceof checks around
  * In C++ we don't have instanceof anyway, so we have to do something
    else.

Also, this definitely addresses b/32275378.
@wilhuff wilhuff force-pushed the wilhuff/consolidate-document branch from ee06751 to 622d9d5 Compare June 15, 2019 21:08
Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[only partially reviewed...]

Hrmm..... I'm not sure if I'm excited about this. My big reservation is that we've significantly expanded the state-space of Document. it's state now consists of {1 of 4 DocumentTypes, 1 of 3 DocumentStates, real snapshot version or not, real value or not} so there are 4 * 3 * 2 *2 = 48 possible combinations, most of which are probably not valid / useful. Before I think we only had 14 combinations (8 for Document, 4 for NoDocument, 2 for UnknownDocument) and (unfortunately) some of those were already invalid...

This makes it harder to reason about code correctness. When writing code that deals with Documents, we theoretically have to make sure it handle all possible state combinations. But that's not feasible or desirable, so instead all of the code around Document will end up with implicit assumptions about the possible states document could actually be in... which can be fragile and hard to read.

We could mitigate to a degree by adding strict assertions (and supporting comments) to Document to clearly establish the expected states a document could actually be in... but runtime validation is not as good as compile-time validation and in general it just seems like we're not doing a good job of mapping our needs onto the right data type if we have so many invalid / non-useful states our data type could be in...

That said, this is mostly an abstract concern and I haven't looked at all the calling code. Maybe it's not that big of a deal. I'll take another look Monday and see how I feel about it.

* denotes time we know it didn't exist at.
*
* A class representing an existing document whose data is unknown (e.g. a
* document that was updated without a known base document).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment needs cleanup.

* Memoized serialized form of the document for optimization purposes (avoids
* repeated serialization). Might be undefined.
*/
memoizedProto?: api.Document;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually used, right? I feel like it should probably not be in this PR. :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is. It was previously accepted as an optional last argument to the Document constructor. This piggybacks on the DocumentOptions now instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh. I think you accidentally dropped storing it then (makeDocumentState() ignores it).

@@ -24,55 +24,162 @@ import { FieldPath } from './path';

import * as api from '../protos/firestore_proto_api';

export interface MissingDocumentOptions {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess {Missing}DocumentOptions exist to supply named parameters to the static constructor methods? They could probably use comments explaining that (I was confused why they exist in addition to DocumentState at first). But I also wonder how we'll want to port this to the other platforms... may make us reconsider the approach for TypeScript.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing surface area. I'm definitely in favor of removing it and just passing DocumentState instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'm in favor of merging DocumentState into DocumentKind and so we probably need to keep these around...

}

toString(): string {
return `NoDocument(${this.key}, ${this.version})`;
get missing(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isMissing?

*/
export abstract class MaybeDocument {
constructor(readonly key: DocumentKey, readonly version: SnapshotVersion) {}
export enum DocumentType {
Copy link
Contributor

@mikelehen mikelehen Jun 15, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put MISSING / EXISTS at the top of this enum, since they are the simple cases...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per our chat conversation, I think I am a fan of combining the DocumentType and DocumentState. Also (see below), I am interested in trying to merge UNKNOWN and CONTENTS_UNKNOWN. I think this would result in the following 6 document types:

DOCUMENT
DOCUMENT_WITH_LOCAL_MUTATIONS
DOCUMENT_WITH_COMMITTED_MUTATIONS
MISSING
MISSING_WITH_LOCAL_MUTATIONS
NO_ENTRY

This takes us from 4*3 = 12 potential kind/state combinations to 6.

super(key, version);
this.hasCommittedMutations = !!(options && options.hasCommittedMutations);
get exists(): boolean {
return this.type === DocumentType.EXISTS;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naively I would think that CONTENTS_UNKNOWN should return true. If it shouldn't, then perhaps .exists is misnamed and really means something else?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below. exists here exactly mirrors the DocumentType so we should rename that to make it clearer (and then update this to match if we're keeping these properties).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per chat conversation I think these helpers should become:

.isDocument
.isMissingDocument
.noEntry ( or .isNoEntry? )

And I'd like to provide the guarantee that exactly 1 is true.

}

get hasPendingWrites(): boolean {
return this.hasCommittedMutations;
get unknown(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isUnknown? Though I'm not sure that's descriptive enough... I'm not immediately thinking of a better name though.

* Returns true if the document is of some definite type: either existing
* or definitely missing.
*/
get definite(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a really hard time guessing what if (fooDoc.definite) { ... } means.

In general, I'm nervous about all of these properties (exists, missing, unknown, definite) because I'm worried they could be confusing / misleading. We're mapping 4 DocumentTypes (actually 3 since CONTENTS_UNKNOWN doesn't show up) onto 4 helper properties and the mapping isn't immediately obvious to me from the names. You might expect that !exists implies missing but that would be wrong.

There may be better terminology that makes everything clearer, but failing that I would be inclined to just drop them all and move the explicit DocumentType checks into the calling code... if that's too onerous though, we can work harder on the names / comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the properties mirror the document types. They just abbreviate doc.type === DocumentType.MISSING to doc.isMissing. If this is confusing I can drop it.

Would renaming EXISTS to DEFINITELY_EXISTS and MISSING to DEFINITELY_MISSING help (and corresponding accessors too if still doing those)?

Could get rid of definite and just inline it into the callers. Alternatively with the rename above it might mean something clearer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per previous comment I'd like to remove this and be able to use !noEntry instead... failing that, perhaps inline it if there aren't too many uses...

Copy link
Contributor Author

@wilhuff wilhuff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrmm..... I'm not sure if I'm excited about this. My big reservation is that we've significantly expanded the state-space of Document. it's state now consists of {1 of 4 DocumentTypes, 1 of 3 DocumentStates, real snapshot version or not, real value or not} so there are 4 * 3 * 2 *2 = 48 possible combinations, most of which are probably not valid / useful. Before I think we only had 14 combinations (8 for Document, 4 for NoDocument, 2 for UnknownDocument) and (unfortunately) some of those were already invalid...

So I started down this path because the C++ version of this with inheritance is nasty. This started out as an experiment to see if I could make this consolidation work at all. By the time I had proven to myself it could work it was very nearly in the state I sent.

As I worked through it I found that in basically every calling case the code got simpler. The realization I had was that for the most part we don't care about the permutations you're describing.

For example:

  • real version or not doesn't matter: we either write the versions out as they are or coerce non-existing document versions to MIN anyway.
  • real value or not doesn't matter: when we want to read a field ObjectValue.EMPTY does the right thing.
  • document state doesn't matter except to the local serializer

Also, there were a bunch of implicit assumptions/special cases that could actually be removed because of this. For example:

  • Callers don't have to know that null MaybeDocuments should have SnapshotVersion.MIN versions
  • Callers don't have to know that only existing documents can have local mutations

One thing I considered doing but didn't end up doing is explicitly encoding the allowed DocumentType x DocumentState values in just a single enum with 7 states:

  • exists (sync, local, committed)
  • missing (sync, committed)
  • content unknown (committed)
  • unknown (sync)

I could be convinced to make this happen (though to do so I do think we need the simplifying accessors the removal of which we're discussing elsewhere).

I think in practice the state explosion you're describing doesn't happen. The factories constrain what can be created and downstream code gets a value that just works. It's only at the very edges (serialization, etc) where we care most about these differences and I don't think this change made that code any worse.

I've included some feedback below to engage in the discussion but haven't made any further changes.

* Returns true if the document is of some definite type: either existing
* or definitely missing.
*/
get definite(): boolean {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the properties mirror the document types. They just abbreviate doc.type === DocumentType.MISSING to doc.isMissing. If this is confusing I can drop it.

Would renaming EXISTS to DEFINITELY_EXISTS and MISSING to DEFINITELY_MISSING help (and corresponding accessors too if still doing those)?

Could get rid of definite and just inline it into the callers. Alternatively with the rename above it might mean something clearer.

super(key, version);
this.hasCommittedMutations = !!(options && options.hasCommittedMutations);
get exists(): boolean {
return this.type === DocumentType.EXISTS;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below. exists here exactly mirrors the DocumentType so we should rename that to make it clearer (and then update this to match if we're keeping these properties).

* Memoized serialized form of the document for optimization purposes (avoids
* repeated serialization). Might be undefined.
*/
memoizedProto?: api.Document;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is. It was previously accepted as an optional last argument to the Document constructor. This piggybacks on the DocumentOptions now instead.

@@ -24,55 +24,162 @@ import { FieldPath } from './path';

import * as api from '../protos/firestore_proto_api';

export interface MissingDocumentOptions {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing surface area. I'm definitely in favor of removing it and just passing DocumentState instead.

Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having finished reviewing this, I have pretty mixed feelings. I think:

  1. Introducing a DocumentEntry type that can represent the lack of a cache entry (including the document key and perhaps a version) is helpful and it's really pleasing to see all the places where MaybeDocument | null turn into DocumentEntry. This is a nice clean-up.
  2. But I am nervous everywhere we have Document or MaybeDocument turn into a DocumentEntry since now our type is too broad and so calling code could pass invalid document entries or get back document entries that it fails to handle properly (since it may have invalid assumptions about the actual types of document entries expected).

Because of 2, this is pretty difficult to review for absolute correctness. And I think it leaves our code more fragile going forward. We can mitigate to some degree by introducing assertions and more explicit comments documenting the contract of each method as far as expected document types go, but ultimately the code is less type-safe and less self-documenting. :-/

Honestly, ignoring C++, my preferred path forward would be that we keep the benefits of #1 by introducing a DocumentEntry class that is a super class of MaybeDocument containing just key and version... but we keep the separate types so we can narrow as appropriate for parts of the system that don't need to deal with all possible document types.

FWIW if we wanted, in TypeScript we could reduce instanceof checks with user-defined type guards: https://basarat.gitbooks.io/typescript/docs/types/typeGuard.html#user-defined-type-guards Doesn't help Android or C++ though. :-/

I'm open to being won over by this change, but right now I'm on-the-fence as-is and the burden of porting and code reviewing this twice more and the risk of bugs makes me suspicious it's not a net-win.

@@ -1130,12 +1112,11 @@ export class DocumentReference implements firestore.DocumentReference {
snapshot.docs.size <= 1,
'Too many documents returned on a document query'
);
const doc = snapshot.docs.get(this._key);
const doc = getDocument(this._key, snapshot.docs);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this helper pays for itself (I couldn't guess what it did, so I had to look it up only to find out it's a pretty trivial 2-liner). What about this?

  const doc = Document.fromNullable(snapshot.docs.get(this._key));

@@ -1290,33 +1269,31 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot {
): unknown {
validateBetweenNumberOfArgs('DocumentSnapshot.get', arguments, 1, 2);
options = validateSnapshotOptions('DocumentSnapshot.get', options);
if (this._document) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super psyched about relying on document.data for nonexistent documents returning an empty ObjectValue instance. While convenient, it seems a bit non-obvious and so this code becomes less obviously-correct. I don't feel super strongly, but my preference would be to not rely on this and in fact to make .data throw for nonexistent documents.

this.verifyNotShutdown();
return this.asyncQueue
.enqueue(() => {
return this.localStore.readDocument(docKey);
})
.then((maybeDoc: MaybeDocument | null) => {
if (maybeDoc instanceof Document) {
.then((maybeDoc: Document) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW in the fullness of time we should probably get rid of maybeDoc references, and (pending final rename decisions) perhaps move doc references toward docEntry etc... but I'm probably in favor of doing this cleanup in separate PRs. :-)

.then((maybeDoc: MaybeDocument | null) => {
if (maybeDoc instanceof Document) {
.then((maybeDoc: Document) => {
if (maybeDoc.definite) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like definite (both the name itself and the mere fact that we're inventing an extra term as an alias for isDocument || isMissingDocument increasing the possibility of terminology confusion).

We could just inline doc.isDocument || doc.isMissingDocument everywhere... but that does seem a bit ugly, since it shows up a lot.

I'm not 100% sure this is a good idea, but I'm wondering if we could get rid of CONTENTS_UNKNOWN and instead just use UNKNOWN but with a real version number (instead of SnapshotVersion.MIN). I think that mostly "just works" and then this code can safely become if (!maybeDoc.noEntry) { ... } and all of our code can count on exactly one of isDocument, isMissingDocument, or noEntry being true.

} else {
throw fail('Document in a transaction was a ' + doc.constructor.name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrm... while this case is no longer possible via the type system, we did introduce a new possibility which is that doc.noEntry could now be true. Should we throw an assert in to make sure (either for !doc.noEntry or for doc.isMissingDocument)?

/**
* Whether this document had a local mutation applied that has not yet been
* acknowledged by Watch.
*/
get hasPendingWrites(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW this is used in exactly one place and so I'm somewhat inclined to inline it (or move the helper closer to where it's used) in order to avoid an extra term exposed here ("local mutations", "committed mutations", "pending writes").

} else if (this.exists !== undefined) {
return this.exists === maybeDoc instanceof Document;
return this.exists === maybeDoc.exists;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-existing nit: I had to question whether or not we are intentionally treating unknown the same as missing, and I think we are... but we should probably either do it explicitly or add a comment.

if (mutatedDocument) {
const baseDocument = getDocument(m.key, maybeDocs);
const mutatedDocument = this.applyToLocalView(m.key, baseDocument);
if (!mutatedDocument.unknown) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might need to change if we merge UNKNOWN / CONTENTS_UNKNOWN. I wonder if we could just drop the if check entirely...

DocumentType.UNKNOWN,
key,
SnapshotVersion.MIN,
ObjectValue.EMPTY,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per my comments elsewhere I think I would prefer if we made .data throw for missing / unknown documents (and optionally added .dataOrEmptyObject or something so you can explicitly opt into ObjectValue.EMPTY

export class UnknownDocument extends MaybeDocument {
constructor(key: DocumentKey, version: SnapshotVersion) {
super(key, version);
get hasLocalMutations(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible I think I'd like hasLocalMutations to throw for unknown / missing documents and hasCommittedMutations to throw for unknown documents... We can loosen if/when we need to, but ensuring code isn't checking for impossible things would be nice.

@mikelehen mikelehen assigned wilhuff and unassigned mikelehen Jun 18, 2019
@wilhuff
Copy link
Contributor Author

wilhuff commented Jun 18, 2019

As I'm reviewing your feedback in occurs to me that an assumption underlying this change was that in practice, most of our code doesn't care about whether or not a document actually exists so long as non-existing document and existing documents behave uniformly. The uniformity would be a feature of the change, not a bug.

Building from there, whether or not a document actually exists or not is just additional metadata that we happen to attach to the document.

For example, this change makes most mutations simpler because no matter the actual state of the document you can apply the mutation and any tests around it.

Of course, there are exceptions:

  • Patch mutations have different behavior for documents that exist vs not.
  • Queries only want to return known existing documents
  • Document gets want known existing or known not existing documents
  • etc

However, naming concerns aside, most of your feedback is essentially pushing back toward non-uniformity or overspecificity. This comment encapsulates this disconnect.

My contention is that this code has two behaviors based on whether or not the document exists or not. Which form of non-existence doesn't matter here so there's no reason to apply a constraint.

If we can't agree that this basic premise is worthwhile then this change isn't really worth fixing. It's gone way too far toward that goal to bring back to true.

@wilhuff wilhuff assigned mikelehen and unassigned wilhuff Jun 18, 2019
@mikelehen
Copy link
Contributor

Hrm. I'm not 100% sure if I'm interpreting your comment correctly. When you say "so long as non-existing document and existing documents behavior uniformly" I'm not sure if you mean "non-existing documents behave uniformly and existing documents behave uniformly" or "(non-existing documents and existing documents) behave uniformly" and in either case I'm not sure if you are considering unknown documents to be included in "non-existing documents"...

FWIW- In the example you linked:

  1. I think treating unknown the same as missing would be incorrect. We should only use a baseVersion of 0 if the document really is a missing document.
  2. The calling code already protects us from .unknown documents, but because that's no longer enforced by the type system, it's no longer obvious / self-documenting.
  3. If part of our goal is to get rid of instanceof checks, we could add .version and .exists (or .isDocument or whatever) to MaybeDocument.

Anyway, let's chat in person after sprint planning.

Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My main concern continues to be that we have broadened the types of "document entry" accepted and returned by components throughout the system. I've attempted to audit the public methods on our major components to determine if and to-what-degree this broadening could be harmful. See results here: #1900

I happened to find two concrete "bugs waiting to happen", though that wasn't my primary goal. And I think many more potential bugs would be found by looking at the code calling the methods I audited, since it's usually the return type that was broadened.

So I think:

  • Introducing a DocumentEntry class to represent MaybeDocument|null is useful to simplify and better document MaybeDocument|null types and allow consuming code to use .key and .version without instanceof checks.
  • Reworking the existing UnknownDocument (aka CONTENTS_UNKNOWN post-refactor) to not be a subclass of MaybeDocument makes sense. I think treating it more like a special case of UNKNOWN (where we happen to have a version) would be better. I experimentally merged UNKNOWN and CONTENTS_UNKNOWN in your PR and it was straightforward to get all the tests to pass.
  • I'm generally in favor of reworking the naming of our document-related types / concepts (MaybeDocument, NoDocument, etc.)
  • I think broadening the Document type throughout our codebase is overall harmful. Specifically I think it is occasionally helpful (we did have some over-specialized cases), sometimes benign (we can make code more generic than it "needs" to be with no harm to the surrounding code), but often mildly harmful (weakening our contracts and self-documentation), and occasionally visibly harmful (introducing potential bugs).

So setting C++ aside, I'm not in favor of dropping our existing type hierarchy.

I don't have a solution for C++. If there's really no good way to implement something akin to this, then maybe we should go ahead and implement the collapsed type hierarchy on C++ (like what's in this PR right now) but use comments or some other convention (macros?) to try to document expectations and make it clear how to port code back/forth to the other platforms.

@mikelehen mikelehen assigned wilhuff and unassigned mikelehen Jun 21, 2019
@wilhuff
Copy link
Contributor Author

wilhuff commented Jul 11, 2019

As discussed, this approach isn't going to work.

@wilhuff wilhuff closed this Jul 11, 2019
@firebase firebase locked and limited conversation to collaborators Oct 10, 2019
@wilhuff wilhuff deleted the wilhuff/consolidate-document branch March 13, 2020 16:25
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants