Skip to content

Commit 6327072

Browse files
authored
Support Firestore Modular SDK through new Adapter and Serializer (#204)
1 parent 0197579 commit 6327072

21 files changed

+1774
-44
lines changed

addon/-private/flatten-doc-snapshot.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DocumentSnapshot } from 'firebase/firestore';
12
import firebase from 'firebase/compat/app';
23

3-
export default function flattenDocSnapshot(docSnapshot: firebase.firestore.DocumentSnapshot): {
4+
export default function flattenDocSnapshot(
5+
docSnapshot: firebase.firestore.DocumentSnapshot | DocumentSnapshot,
6+
): {
47
id: string,
58
[key: string]: unknown,
69
} {
+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*
2+
eslint
3+
@typescript-eslint/no-empty-function: off,
4+
ember/use-ember-data-rfc-395-imports: off
5+
*/
6+
7+
import { next } from '@ember/runloop';
8+
import DS from 'ember-data';
9+
import Store from '@ember-data/store';
10+
11+
import { CollectionReference, DocumentReference, Query } from 'firebase/firestore';
12+
13+
import { onSnapshot } from 'ember-cloud-firestore-adapter/firebase/firestore';
14+
import flattenDocSnapshotData from 'ember-cloud-firestore-adapter/-private/flatten-doc-snapshot';
15+
16+
interface ModelTracker {
17+
[key: string]: {
18+
record: {
19+
[key: string]: { hasOnSnapshotRunAtLeastOnce: boolean },
20+
},
21+
meta: { hasOnSnapshotRunAtLeastOnce: boolean, hasTrackedAllRecords: boolean },
22+
};
23+
}
24+
25+
interface QueryTracker {
26+
[key: string]: {
27+
hasOnSnapshotRunAtLeastOnce: boolean,
28+
29+
unsubscribe(): void,
30+
};
31+
}
32+
33+
export default class RealtimeTracker {
34+
private modelTracker: ModelTracker = {};
35+
36+
private queryTracker: QueryTracker = {};
37+
38+
private store: Store;
39+
40+
constructor(store: Store) {
41+
this.store = store;
42+
}
43+
44+
public trackFindRecordChanges(modelName: string, docRef: DocumentReference): void {
45+
const { id } = docRef;
46+
47+
if (!this.isRecordTracked(modelName, id)) {
48+
this.trackModel(modelName);
49+
50+
this.modelTracker[modelName].record[id] = { hasOnSnapshotRunAtLeastOnce: false };
51+
52+
const unsubscribe = onSnapshot(docRef, (docSnapshot) => {
53+
if (this.modelTracker[modelName].record[id].hasOnSnapshotRunAtLeastOnce) {
54+
if (docSnapshot.exists()) {
55+
const record = this.store.peekRecord(modelName, id);
56+
57+
if (record && !record.isSaving) {
58+
const flatRecord = flattenDocSnapshotData(docSnapshot);
59+
const normalizedRecord = this.store.normalize(modelName, flatRecord);
60+
61+
this.store.push(normalizedRecord);
62+
}
63+
} else {
64+
unsubscribe();
65+
this.unloadRecord(modelName, id);
66+
}
67+
} else {
68+
this.modelTracker[modelName].record[id].hasOnSnapshotRunAtLeastOnce = true;
69+
}
70+
}, (error) => {
71+
const record = this.store.peekRecord(modelName, id);
72+
73+
if (record) {
74+
// When we lose permission to view the document, we unload it from the store. However,
75+
// any template that has rendered the record will still be intact even if it no longer
76+
// exists in the store.
77+
//
78+
// We set some properties here to give our templates the opportunity to react to this
79+
// scenario.
80+
record.set('isUnloaded', true);
81+
record.set('unloadReason', error);
82+
this.unloadRecord(modelName, id);
83+
}
84+
85+
delete this.modelTracker[modelName].record[id];
86+
});
87+
}
88+
}
89+
90+
public trackFindAllChanges(modelName: string, collectionRef: CollectionReference): void {
91+
if (!this.modelTracker[modelName]?.meta.hasTrackedAllRecords) {
92+
this.trackModel(modelName);
93+
94+
onSnapshot(collectionRef, (querySnapshot) => {
95+
if (this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce) {
96+
querySnapshot.forEach((docSnapshot) => (
97+
this.store.findRecord(modelName, docSnapshot.id, {
98+
adapterOptions: { isRealtime: true },
99+
})
100+
));
101+
} else {
102+
this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce = true;
103+
}
104+
}, () => {
105+
this.modelTracker[modelName].meta.hasTrackedAllRecords = false;
106+
});
107+
108+
this.modelTracker[modelName].meta.hasTrackedAllRecords = true;
109+
this.modelTracker[modelName].meta.hasOnSnapshotRunAtLeastOnce = false;
110+
}
111+
}
112+
113+
public trackFindHasManyChanges(
114+
modelName: string | number,
115+
id: string,
116+
field: string,
117+
collectionRef: CollectionReference | Query,
118+
): void {
119+
const queryId = `${modelName}_${id}_${field}`;
120+
121+
if (!Object.prototype.hasOwnProperty.call(this.queryTracker, queryId)) {
122+
this.queryTracker[queryId] = {
123+
hasOnSnapshotRunAtLeastOnce: false,
124+
unsubscribe: () => {},
125+
};
126+
}
127+
128+
const unsubscribe = onSnapshot(collectionRef, () => {
129+
if (this.queryTracker[queryId].hasOnSnapshotRunAtLeastOnce) {
130+
// Schedule for next runloop to avoid race condition errors for when a record is unloaded
131+
// in the find record tracker because it was deleted in the database. Basically, we should
132+
// unload any deleted records first before refreshing the has-many array.
133+
next(() => {
134+
const hasManyRef = this.store.peekRecord(modelName, id).hasMany(field);
135+
136+
hasManyRef.reload().then(() => this.queryTracker[queryId].unsubscribe());
137+
});
138+
} else {
139+
this.queryTracker[queryId].hasOnSnapshotRunAtLeastOnce = true;
140+
}
141+
}, () => delete this.queryTracker[queryId]);
142+
143+
this.queryTracker[queryId].unsubscribe = unsubscribe;
144+
}
145+
146+
public trackQueryChanges(
147+
firestoreQuery: Query,
148+
recordArray: DS.AdapterPopulatedRecordArray<unknown>,
149+
queryId?: string,
150+
): void {
151+
const finalQueryId = queryId || Math.random().toString(32).slice(2).substr(0, 5);
152+
153+
if (!Object.prototype.hasOwnProperty.call(this.queryTracker, finalQueryId)) {
154+
this.queryTracker[finalQueryId] = {
155+
hasOnSnapshotRunAtLeastOnce: false,
156+
unsubscribe: () => {},
157+
};
158+
}
159+
160+
const unsubscribe = onSnapshot(firestoreQuery, () => {
161+
if (this.queryTracker[finalQueryId].hasOnSnapshotRunAtLeastOnce) {
162+
// Schedule for next runloop to avoid race condition errors for when a record is unloaded
163+
// in the find record tracker because it was deleted in the database. Basically, we should
164+
// unload any deleted records first before refreshing the query array.
165+
next(() => (
166+
recordArray.update().then(() => this.queryTracker[finalQueryId].unsubscribe())
167+
));
168+
} else {
169+
this.queryTracker[finalQueryId].hasOnSnapshotRunAtLeastOnce = true;
170+
}
171+
}, () => delete this.queryTracker[finalQueryId]);
172+
173+
this.queryTracker[finalQueryId].unsubscribe = unsubscribe;
174+
}
175+
176+
private isRecordTracked(modelName: string, id: string): boolean {
177+
return this.modelTracker[modelName]?.record?.[id] !== undefined;
178+
}
179+
180+
private trackModel(type: string): void {
181+
if (!Object.prototype.hasOwnProperty.call(this.modelTracker, type)) {
182+
this.modelTracker[type] = {
183+
meta: {
184+
hasOnSnapshotRunAtLeastOnce: false,
185+
hasTrackedAllRecords: false,
186+
},
187+
record: {},
188+
};
189+
}
190+
}
191+
192+
private unloadRecord(modelName: string, id: string): void {
193+
const record = this.store.peekRecord(modelName, id);
194+
195+
if (record && !record.isSaving) {
196+
this.store.unloadRecord(record);
197+
}
198+
199+
delete this.modelTracker[modelName].record[id];
200+
}
201+
}

0 commit comments

Comments
 (0)