@rebasepro/firebase 0.13.0 → 0.13.1-canary.g06dbe5b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/RebaseFirebaseAppProps.d.ts +1 -1
- package/dist/hooks/useBuildUserManagement.d.ts +29 -0
- package/dist/hooks/useFirebaseAuthController.d.ts +14 -0
- package/dist/hooks/useFirebaseRealTimeDBDelegate.d.ts +39 -1
- package/dist/hooks/useFirestoreDriver.d.ts +20 -0
- package/dist/index.es.js +168 -28
- package/dist/index.es.js.map +1 -1
- package/package.json +8 -8
- package/src/components/RebaseFirebaseAppProps.tsx +1 -1
- package/src/hooks/useBuildUserManagement.tsx +66 -5
- package/src/hooks/useFirebaseAuthController.ts +28 -3
- package/src/hooks/useFirebaseRealTimeDBDelegate.ts +183 -29
- package/src/hooks/useFirestoreDriver.ts +40 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/firebase",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.1-canary.g06dbe5b",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
"fast-equals": "6.0.2",
|
|
15
15
|
"fuse.js": "^7.5.0",
|
|
16
16
|
"react-router": "^8.3.0",
|
|
17
|
-
"@rebasepro/admin": "0.13.
|
|
18
|
-
"@rebasepro/
|
|
19
|
-
"@rebasepro/
|
|
20
|
-
"@rebasepro/common": "0.13.
|
|
21
|
-
"@rebasepro/
|
|
22
|
-
"@rebasepro/
|
|
23
|
-
"@rebasepro/ui": "0.13.
|
|
17
|
+
"@rebasepro/admin": "0.13.1-canary.g06dbe5b",
|
|
18
|
+
"@rebasepro/app": "0.13.1-canary.g06dbe5b",
|
|
19
|
+
"@rebasepro/admin-types": "0.13.1-canary.g06dbe5b",
|
|
20
|
+
"@rebasepro/common": "0.13.1-canary.g06dbe5b",
|
|
21
|
+
"@rebasepro/utils": "0.13.1-canary.g06dbe5b",
|
|
22
|
+
"@rebasepro/types": "0.13.1-canary.g06dbe5b",
|
|
23
|
+
"@rebasepro/ui": "0.13.1-canary.g06dbe5b"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"firebase": "^10.12.2 || ^11.0.0 || ^12.0.0",
|
|
@@ -57,6 +57,51 @@ export interface UserManagementDelegateParams<CONTROLLER extends AuthController<
|
|
|
57
57
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Why the access gate answered the way it did.
|
|
62
|
+
*
|
|
63
|
+
* `users-unreadable` is a state of its own because it used to be
|
|
64
|
+
* indistinguishable from `bootstrap`: the users listener's `onError` empties
|
|
65
|
+
* the user list, so a `permission-denied` on the users path — a rules
|
|
66
|
+
* misconfiguration, a rules deploy that has not landed, a renamed path —
|
|
67
|
+
* reached the gate as "no users created yet" and every authenticated user was
|
|
68
|
+
* let in. An unreadable list is not an empty one, and only one of the two may
|
|
69
|
+
* open the door.
|
|
70
|
+
*/
|
|
71
|
+
export type AccessDecision =
|
|
72
|
+
| "loading"
|
|
73
|
+
| "no-user"
|
|
74
|
+
| "users-unreadable"
|
|
75
|
+
| "bootstrap"
|
|
76
|
+
| "known-user"
|
|
77
|
+
| "unknown-user";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether a user may access the CMS, given the state of the user
|
|
81
|
+
* management collection.
|
|
82
|
+
*
|
|
83
|
+
* A plain function rather than logic inside the gate callback so the states
|
|
84
|
+
* that must stay distinct can be asserted without a React render.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveAccessDecision({
|
|
87
|
+
loading,
|
|
88
|
+
usersError,
|
|
89
|
+
users,
|
|
90
|
+
user
|
|
91
|
+
}: {
|
|
92
|
+
loading: boolean;
|
|
93
|
+
usersError?: Error;
|
|
94
|
+
users: { email?: string | null }[];
|
|
95
|
+
user: { email?: string | null } | null;
|
|
96
|
+
}): AccessDecision {
|
|
97
|
+
if (loading) return "loading";
|
|
98
|
+
if (!user) return "no-user";
|
|
99
|
+
if (usersError) return "users-unreadable";
|
|
100
|
+
if (users.length === 0) return "bootstrap";
|
|
101
|
+
const known = users.some(u => u.email?.toLowerCase() === user.email?.toLowerCase());
|
|
102
|
+
return known ? "known-user" : "unknown-user";
|
|
103
|
+
}
|
|
104
|
+
|
|
60
105
|
/**
|
|
61
106
|
* This hook is used to build a user management object that can be used to
|
|
62
107
|
* manage users and roles in a Firestore backend.
|
|
@@ -265,21 +310,37 @@ export function useBuildUserManagement<CONTROLLER extends AuthController<User> =
|
|
|
265
310
|
|
|
266
311
|
const accessGate: FirebaseAccessGate<USER> = useCallback(({ user }) => {
|
|
267
312
|
|
|
268
|
-
|
|
313
|
+
const decision = resolveAccessDecision({
|
|
314
|
+
loading,
|
|
315
|
+
usersError,
|
|
316
|
+
users,
|
|
317
|
+
user
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (decision === "loading") {
|
|
269
321
|
return false;
|
|
270
322
|
}
|
|
271
|
-
|
|
323
|
+
|
|
324
|
+
if (decision === "no-user") {
|
|
272
325
|
console.warn("User is null, returning");
|
|
273
326
|
return false;
|
|
274
327
|
}
|
|
275
328
|
|
|
276
|
-
if (
|
|
329
|
+
if (decision === "users-unreadable") {
|
|
330
|
+
// The users collection could not be read, so we do not know whether
|
|
331
|
+
// this user is in it. Denying is the only safe reading: the
|
|
332
|
+
// bootstrap branch below would otherwise admit everyone.
|
|
333
|
+
console.error("Denying access: the user management collection could not be read", usersError);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (decision === "bootstrap") {
|
|
277
338
|
console.warn("No users created yet");
|
|
278
339
|
return true; // If there are no users created yet, we allow access to every user
|
|
279
340
|
}
|
|
280
341
|
|
|
281
342
|
const mgmtUser = users.find(u => u.email?.toLowerCase() === user?.email?.toLowerCase());
|
|
282
|
-
if (mgmtUser) {
|
|
343
|
+
if (decision === "known-user" && mgmtUser && user) {
|
|
283
344
|
// check if the uid or photoURL needs to be updated in the user management system
|
|
284
345
|
const needsUidUpdate = mgmtUser.uid !== user.uid;
|
|
285
346
|
const needsPhotoUpdate = user.photoURL && mgmtUser.photoURL !== user.photoURL;
|
|
@@ -302,7 +363,7 @@ export function useBuildUserManagement<CONTROLLER extends AuthController<User> =
|
|
|
302
363
|
}
|
|
303
364
|
|
|
304
365
|
throw Error("Could not find a user with the provided email in the user management system.");
|
|
305
|
-
}, [loading, users]);
|
|
366
|
+
}, [loading, users, usersError]);
|
|
306
367
|
|
|
307
368
|
const userRoles = authController.user ? defineRolesFor(authController.user) : undefined;
|
|
308
369
|
const isAdmin = (userRoles ?? []).some(r => r === "admin");
|
|
@@ -26,6 +26,28 @@ import { FirebaseApp } from "firebase/app";
|
|
|
26
26
|
import { FirebaseAuthController, FirebaseSignInOption, FirebaseSignInProvider, FirebaseUserWrapper } from "../types";
|
|
27
27
|
import type { User } from "@rebasepro/types";
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `user`'s roles through `defineRolesFor` and report whether they
|
|
31
|
+
* differ from the ones already applied.
|
|
32
|
+
*
|
|
33
|
+
* A plain function rather than a check inside the hook because the check that
|
|
34
|
+
* used to live there read `!equal(userRoles, userRoles)` — the local shadowed
|
|
35
|
+
* the state of the same name, so the fresh roles were compared to themselves,
|
|
36
|
+
* the guard was never true, and a `defineRolesFor` result arriving after the
|
|
37
|
+
* auth-state change never reached the controller.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveRoleRefresh(
|
|
40
|
+
defineRolesFor: (user: User) => Promise<string[] | undefined> | string[] | undefined,
|
|
41
|
+
user: User,
|
|
42
|
+
currentRoles: string[] | undefined
|
|
43
|
+
): Promise<{ changed: boolean, roles: string[] | undefined }> {
|
|
44
|
+
const roles = await defineRolesFor(user);
|
|
45
|
+
return {
|
|
46
|
+
changed: !equal(currentRoles, roles),
|
|
47
|
+
roles
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
export interface FirebaseAuthControllerProps {
|
|
30
52
|
loading?: boolean;
|
|
31
53
|
firebaseApp?: FirebaseApp;
|
|
@@ -78,9 +100,12 @@ export const useFirebaseAuthController = <USER extends FirebaseUserWrapper = any
|
|
|
78
100
|
|
|
79
101
|
const updateRoles = useCallback(async (user: User | null) => {
|
|
80
102
|
if (defineRolesFor && user) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
const {
|
|
104
|
+
changed,
|
|
105
|
+
roles
|
|
106
|
+
} = await resolveRoleRefresh(defineRolesFor, user, userRoles);
|
|
107
|
+
if (changed) {
|
|
108
|
+
setUserRoles(roles);
|
|
84
109
|
}
|
|
85
110
|
}
|
|
86
111
|
}, [defineRolesFor, userRoles]);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { FirebaseApp } from "firebase/app";
|
|
2
2
|
import {
|
|
3
3
|
Database,
|
|
4
|
+
endAt,
|
|
5
|
+
equalTo,
|
|
4
6
|
get,
|
|
5
7
|
getDatabase,
|
|
6
8
|
limitToFirst,
|
|
@@ -9,43 +11,189 @@ import {
|
|
|
9
11
|
orderByKey,
|
|
10
12
|
push,
|
|
11
13
|
query,
|
|
14
|
+
QueryConstraint,
|
|
12
15
|
ref,
|
|
13
16
|
remove,
|
|
14
17
|
set,
|
|
18
|
+
startAfter,
|
|
15
19
|
startAt
|
|
16
20
|
} from "firebase/database";
|
|
17
21
|
import { useCallback } from "react";
|
|
18
|
-
import { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, FilterValues, ListenCollectionProps, ListenOneProps, SaveProps } from "@rebasepro/types";
|
|
22
|
+
import { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, FilterValues, ListenCollectionProps, ListenOneProps, SaveProps, WhereFilterOp } from "@rebasepro/types";
|
|
23
|
+
|
|
24
|
+
/** The values the Realtime Database can order or bound a query by. */
|
|
25
|
+
type RTDBFilterValue = string | number | boolean | null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A read expressed in the Realtime Database's own query model.
|
|
29
|
+
*
|
|
30
|
+
* @see planRTDBQuery
|
|
31
|
+
*/
|
|
32
|
+
export type RTDBQueryPlan = {
|
|
33
|
+
/** Child key to order — and therefore to bound — by. Absent means order by key. */
|
|
34
|
+
orderByChild?: string;
|
|
35
|
+
equalTo?: RTDBFilterValue;
|
|
36
|
+
startAt?: RTDBFilterValue;
|
|
37
|
+
/** Key the window starts after, exclusive. Only valid in key order. */
|
|
38
|
+
startAfter?: RTDBFilterValue;
|
|
39
|
+
endAt?: RTDBFilterValue;
|
|
40
|
+
limitToFirst?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Rows to drop from the front of the result — the caller's `offset`, which
|
|
43
|
+
* the database has no constraint for. Applied by the caller, not by
|
|
44
|
+
* {@link rtdbConstraints}.
|
|
45
|
+
*/
|
|
46
|
+
skip?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const RTDB = "useFirebaseRTDBDelegate";
|
|
50
|
+
|
|
51
|
+
const isRTDBValue = (value: unknown): value is RTDBFilterValue =>
|
|
52
|
+
value === null ||
|
|
53
|
+
typeof value === "string" ||
|
|
54
|
+
typeof value === "number" ||
|
|
55
|
+
typeof value === "boolean";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Translate a driver read into the Realtime Database's query model, or refuse it.
|
|
59
|
+
*
|
|
60
|
+
* The Realtime Database orders by a single child key per query and bounds that
|
|
61
|
+
* one key with `equalTo`/`startAt`/`endAt`. Nothing else is expressible: no
|
|
62
|
+
* second field, no descending order, no text search, no `or(...)` group.
|
|
63
|
+
*
|
|
64
|
+
* Everything beyond `limit` and `startAfter` used to be destructured out of the
|
|
65
|
+
* read and then never referenced, so a caller asking for `status == "draft"`
|
|
66
|
+
* was handed the entire collection, presented as the answer. A query this
|
|
67
|
+
* database cannot express is refused here instead — a caller that sees an error
|
|
68
|
+
* can fall back, a caller that sees the wrong rows cannot.
|
|
69
|
+
*/
|
|
70
|
+
export function planRTDBQuery<M extends Record<string, any>>({
|
|
71
|
+
filter,
|
|
72
|
+
orderBy,
|
|
73
|
+
order,
|
|
74
|
+
searchString,
|
|
75
|
+
logical,
|
|
76
|
+
limit,
|
|
77
|
+
offset,
|
|
78
|
+
startAfter: startAfterKey
|
|
79
|
+
}: Pick<FetchCollectionProps<M>, "filter" | "orderBy" | "order" | "searchString" | "logical" | "limit" | "offset" | "startAfter">): RTDBQueryPlan {
|
|
80
|
+
|
|
81
|
+
if (searchString) {
|
|
82
|
+
throw new Error(`${RTDB}: the Realtime Database has no text search, so \`searchString\` cannot be applied. Index the data in a search service instead.`);
|
|
83
|
+
}
|
|
84
|
+
if (logical) {
|
|
85
|
+
throw new Error(`${RTDB}: the Realtime Database cannot evaluate \`or(...)\`/\`and(...)\` groups.`);
|
|
86
|
+
}
|
|
87
|
+
if (order === "desc") {
|
|
88
|
+
throw new Error(`${RTDB}: the Realtime Database only orders ascending, so \`order: "desc"\` cannot be applied.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const conditions: [string, WhereFilterOp, unknown][] = [];
|
|
92
|
+
Object.entries((filter ?? {}) as FilterValues<string>).forEach(([key, entry]) => {
|
|
93
|
+
if (!entry) return;
|
|
94
|
+
const tuples = Array.isArray(entry[0])
|
|
95
|
+
? entry as [WhereFilterOp, unknown][]
|
|
96
|
+
: [entry as [WhereFilterOp, unknown]];
|
|
97
|
+
tuples.forEach(([op, value]) => conditions.push([key, op, value]));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const fields = Array.from(new Set(conditions.map(([key]) => key)));
|
|
101
|
+
if (fields.length > 1) {
|
|
102
|
+
throw new Error(`${RTDB}: the Realtime Database filters on one child key per query; this read asked for ${fields.join(", ")}.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [field] = fields;
|
|
106
|
+
if (field && orderBy && orderBy !== field) {
|
|
107
|
+
throw new Error(`${RTDB}: a query is ordered by the key it filters on; cannot filter \`${field}\` while ordering by \`${orderBy}\`.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const orderChild = field ?? orderBy;
|
|
111
|
+
const plan: RTDBQueryPlan = orderChild ? { orderByChild: orderChild } : {};
|
|
112
|
+
|
|
113
|
+
for (const [key, op, value] of conditions) {
|
|
114
|
+
if (!isRTDBValue(value)) {
|
|
115
|
+
throw new Error(`${RTDB}: cannot bound \`${key}\` by a ${Array.isArray(value) ? "array" : typeof value} value; the Realtime Database compares strings, numbers, booleans and null.`);
|
|
116
|
+
}
|
|
117
|
+
if (op === "==") {
|
|
118
|
+
if (conditions.length > 1) {
|
|
119
|
+
throw new Error(`${RTDB}: \`==\` bounds a query on its own; it cannot be combined with another condition on \`${key}\`.`);
|
|
120
|
+
}
|
|
121
|
+
plan.equalTo = value;
|
|
122
|
+
} else if (op === ">=") {
|
|
123
|
+
plan.startAt = value;
|
|
124
|
+
} else if (op === "<=") {
|
|
125
|
+
plan.endAt = value;
|
|
126
|
+
} else {
|
|
127
|
+
throw new Error(`${RTDB}: the Realtime Database does not support the "${op}" operator (on \`${key}\`). It bounds a single child key with ==, >= and <=.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (startAfterKey !== undefined) {
|
|
132
|
+
if (orderChild) {
|
|
133
|
+
throw new Error(`${RTDB}: \`startAfter\` pages in key order and cannot be combined with a filter or \`orderBy\`.`);
|
|
134
|
+
}
|
|
135
|
+
plan.startAfter = String(startAfterKey);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// No constraint expresses `offset`, so the window is read `offset` rows
|
|
139
|
+
// wider and the front is dropped. Dropping it instead — which is what this
|
|
140
|
+
// driver did — serves page one to every page, and a paginated walk that
|
|
141
|
+
// takes the driver at its word never terminates.
|
|
142
|
+
const skip = offset !== undefined && Number.isFinite(offset) && offset > 0
|
|
143
|
+
? Math.floor(offset)
|
|
144
|
+
: 0;
|
|
145
|
+
if (skip > 0) {
|
|
146
|
+
plan.skip = skip;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (limit !== undefined) {
|
|
150
|
+
plan.limitToFirst = limit + skip;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return plan;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The plan as Realtime Database query constraints. */
|
|
157
|
+
function rtdbConstraints(plan: RTDBQueryPlan): QueryConstraint[] {
|
|
158
|
+
const constraints: QueryConstraint[] = [];
|
|
159
|
+
const bounded = plan.equalTo !== undefined ||
|
|
160
|
+
plan.startAt !== undefined ||
|
|
161
|
+
plan.startAfter !== undefined ||
|
|
162
|
+
plan.endAt !== undefined;
|
|
163
|
+
|
|
164
|
+
if (plan.orderByChild !== undefined) {
|
|
165
|
+
constraints.push(orderByChild(plan.orderByChild));
|
|
166
|
+
} else if (bounded) {
|
|
167
|
+
constraints.push(orderByKey());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (plan.equalTo !== undefined) constraints.push(equalTo(plan.equalTo));
|
|
171
|
+
if (plan.startAt !== undefined) constraints.push(startAt(plan.startAt));
|
|
172
|
+
if (plan.startAfter !== undefined) constraints.push(startAfter(plan.startAfter));
|
|
173
|
+
if (plan.endAt !== undefined) constraints.push(endAt(plan.endAt));
|
|
174
|
+
if (plan.limitToFirst !== undefined) constraints.push(limitToFirst(plan.limitToFirst));
|
|
175
|
+
|
|
176
|
+
return constraints;
|
|
177
|
+
}
|
|
19
178
|
|
|
20
179
|
export function useFirebaseRTDBDelegate({ firebaseApp }: { firebaseApp?: FirebaseApp }): DataDriver {
|
|
21
180
|
|
|
22
|
-
const fetchCollection = useCallback(async <M extends Record<string, any>>(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
limit,
|
|
26
|
-
startAfter,
|
|
27
|
-
orderBy,
|
|
28
|
-
order,
|
|
29
|
-
searchString
|
|
30
|
-
}: FetchCollectionProps<M>): Promise<Record<string, unknown>[]> => {
|
|
181
|
+
const fetchCollection = useCallback(async <M extends Record<string, any>>(
|
|
182
|
+
props: FetchCollectionProps<M>
|
|
183
|
+
): Promise<Record<string, unknown>[]> => {
|
|
31
184
|
if (!firebaseApp) {
|
|
32
185
|
throw new Error("Firebase app not provided");
|
|
33
186
|
}
|
|
34
187
|
const database = getDatabase(firebaseApp);
|
|
35
188
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
dbQuery = query(dbQuery, orderByKey(), startAt(String(startAfter)));
|
|
41
|
-
}
|
|
42
|
-
if (limit !== undefined) {
|
|
43
|
-
dbQuery = query(dbQuery, limitToFirst(limit));
|
|
44
|
-
}
|
|
189
|
+
// Throws on any narrowing this database cannot express, rather than
|
|
190
|
+
// answering a filtered read with the whole collection.
|
|
191
|
+
const plan = planRTDBQuery(props);
|
|
192
|
+
const dbQuery = query(ref(database, props.path), ...rtdbConstraints(plan));
|
|
45
193
|
|
|
46
194
|
const entity = await get(dbQuery);
|
|
47
195
|
if (entity.exists()) {
|
|
48
|
-
return Object.entries(entity.val()).map(([id, values]) => ({
|
|
196
|
+
return Object.entries(entity.val()).slice(plan.skip ?? 0).map(([id, values]) => ({
|
|
49
197
|
...(delegateToCMSModel(values) as Record<string, unknown>),
|
|
50
198
|
id
|
|
51
199
|
}));
|
|
@@ -53,20 +201,26 @@ export function useFirebaseRTDBDelegate({ firebaseApp }: { firebaseApp?: Firebas
|
|
|
53
201
|
return [];
|
|
54
202
|
}, [firebaseApp]);
|
|
55
203
|
|
|
56
|
-
const listenCollection = useCallback(<M extends Record<string, any>>(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// Realtime Database does not directly support onError in onValue
|
|
60
|
-
}: ListenCollectionProps<M>): () => void => {
|
|
204
|
+
const listenCollection = useCallback(<M extends Record<string, any>>(
|
|
205
|
+
props: ListenCollectionProps<M>
|
|
206
|
+
): () => void => {
|
|
61
207
|
if (!firebaseApp) {
|
|
62
208
|
throw new Error("Firebase app not provided");
|
|
63
209
|
}
|
|
64
210
|
const database = getDatabase(firebaseApp);
|
|
65
211
|
|
|
66
|
-
const
|
|
67
|
-
|
|
212
|
+
const {
|
|
213
|
+
onUpdate,
|
|
214
|
+
onError
|
|
215
|
+
} = props;
|
|
216
|
+
|
|
217
|
+
// Same refusal as `fetchCollection`: this used to read the whole node
|
|
218
|
+
// regardless of what the subscription asked for.
|
|
219
|
+
const plan = planRTDBQuery(props);
|
|
220
|
+
const dbQuery = query(ref(database, props.path), ...rtdbConstraints(plan));
|
|
221
|
+
const unsubscribe = onValue(dbQuery, (entity) => {
|
|
68
222
|
if (entity.exists()) {
|
|
69
|
-
const result: Record<string, unknown>[] = Object.entries(entity.val()).map(([id, values]) => ({
|
|
223
|
+
const result: Record<string, unknown>[] = Object.entries(entity.val()).slice(plan.skip ?? 0).map(([id, values]) => ({
|
|
70
224
|
...(delegateToCMSModel(values) as Record<string, unknown>),
|
|
71
225
|
id
|
|
72
226
|
}));
|
|
@@ -74,7 +228,7 @@ export function useFirebaseRTDBDelegate({ firebaseApp }: { firebaseApp?: Firebas
|
|
|
74
228
|
} else {
|
|
75
229
|
onUpdate([]);
|
|
76
230
|
}
|
|
77
|
-
});
|
|
231
|
+
}, (error) => onError?.(error));
|
|
78
232
|
|
|
79
233
|
return () => unsubscribe();
|
|
80
234
|
}, [firebaseApp]);
|
|
@@ -75,6 +75,35 @@ export type FirestoreDataDriver = DataDriver & {
|
|
|
75
75
|
}) => Promise<boolean>,
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The window a read has to ask Firestore for in order to honour `offset`.
|
|
80
|
+
*
|
|
81
|
+
* Firestore's web SDK has no `offset()` — it pages by cursor (`startAfter`)
|
|
82
|
+
* only. Callers that page by offset (`buildRebaseData`, and through it every
|
|
83
|
+
* `findAll()` and `iterate()`) were therefore served page one every time:
|
|
84
|
+
* `count` is a real server count, so `hasMore` never went false, the walk
|
|
85
|
+
* accumulated the same rows over and over, and it ended by tripping its row
|
|
86
|
+
* cap and reporting "matched more than N rows" — a condition that had not
|
|
87
|
+
* occurred.
|
|
88
|
+
*
|
|
89
|
+
* So the read asks for `offset + limit` documents and drops the first
|
|
90
|
+
* `offset`. Those documents are billed either way: Firestore charges for every
|
|
91
|
+
* document a cursor walks past, which is why `startAfter` is the cheap way to
|
|
92
|
+
* page and offset paging over a large collection is not.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveOffsetWindow(
|
|
95
|
+
limit: number | undefined,
|
|
96
|
+
offset: number | undefined
|
|
97
|
+
): { fetchLimit: number | undefined, skip: number } {
|
|
98
|
+
const skip = offset !== undefined && Number.isFinite(offset) && offset > 0
|
|
99
|
+
? Math.floor(offset)
|
|
100
|
+
: 0;
|
|
101
|
+
return {
|
|
102
|
+
fetchLimit: limit === undefined ? undefined : limit + skip,
|
|
103
|
+
skip
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
78
107
|
/**
|
|
79
108
|
* Use this hook to build a {@link DataDriver} based on Firestore
|
|
80
109
|
* @param firebaseApp
|
|
@@ -298,11 +327,12 @@ export function useFirestoreDriver({
|
|
|
298
327
|
* @param collection
|
|
299
328
|
* @param filter
|
|
300
329
|
* @param limit
|
|
330
|
+
* @param offset
|
|
301
331
|
* @param startAfter
|
|
302
332
|
* @param searchString
|
|
303
333
|
* @param orderBy
|
|
304
334
|
* @param order
|
|
305
|
-
* @return
|
|
335
|
+
* @return The rows in the requested window
|
|
306
336
|
* @see useCollection if you need this functionality implemented as a hook
|
|
307
337
|
* @group Firestore
|
|
308
338
|
*/
|
|
@@ -310,6 +340,7 @@ export function useFirestoreDriver({
|
|
|
310
340
|
path,
|
|
311
341
|
filter,
|
|
312
342
|
limit,
|
|
343
|
+
offset,
|
|
313
344
|
startAfter,
|
|
314
345
|
searchString,
|
|
315
346
|
orderBy,
|
|
@@ -325,15 +356,21 @@ export function useFirestoreDriver({
|
|
|
325
356
|
console.debug("Fetching collection", {
|
|
326
357
|
path,
|
|
327
358
|
limit,
|
|
359
|
+
offset,
|
|
328
360
|
filter,
|
|
329
361
|
startAfter,
|
|
330
362
|
orderBy,
|
|
331
363
|
order
|
|
332
364
|
});
|
|
333
|
-
|
|
365
|
+
// Firestore has no `offset()`; see resolveOffsetWindow.
|
|
366
|
+
const {
|
|
367
|
+
fetchLimit,
|
|
368
|
+
skip
|
|
369
|
+
} = resolveOffsetWindow(limit, offset);
|
|
370
|
+
const query = buildQuery(resolvedPath, filter, orderBy, order, startAfter as unknown[] | undefined, fetchLimit, databaseId);
|
|
334
371
|
|
|
335
372
|
const entity = await getDocs(query);
|
|
336
|
-
return entity.docs.map((doc) => createRowFromDocument(doc));
|
|
373
|
+
return entity.docs.slice(skip).map((doc) => createRowFromDocument(doc));
|
|
337
374
|
}, [buildQuery]);
|
|
338
375
|
|
|
339
376
|
/**
|