@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/firebase",
3
3
  "type": "module",
4
- "version": "0.13.0",
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.0",
18
- "@rebasepro/admin-types": "0.13.0",
19
- "@rebasepro/app": "0.13.0",
20
- "@rebasepro/common": "0.13.0",
21
- "@rebasepro/types": "0.13.0",
22
- "@rebasepro/utils": "0.13.0",
23
- "@rebasepro/ui": "0.13.0"
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",
@@ -147,7 +147,7 @@ export type RebaseFirebaseAppProps = {
147
147
  plugins?: RebasePlugin[];
148
148
 
149
149
  /**
150
- * Open the drawer on hover. Defaults to `false`
150
+ * Open the drawer on hover. Defaults to `true`
151
151
  */
152
152
  autoOpenDrawer?: boolean;
153
153
 
@@ -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
- if (loading) {
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
- if (user === null) {
323
+
324
+ if (decision === "no-user") {
272
325
  console.warn("User is null, returning");
273
326
  return false;
274
327
  }
275
328
 
276
- if (users.length === 0) {
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 userRoles = await defineRolesFor(user);
82
- if (!equal(userRoles, userRoles)) {
83
- setUserRoles(userRoles);
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
- path,
24
- filter,
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
- let dbQuery = query(ref(database, path));
37
-
38
- // Example to apply "limit" and "startAfter"
39
- if (startAfter !== undefined) {
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
- path,
58
- onUpdate
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 dbRef = ref(database, path);
67
- const unsubscribe = onValue(dbRef, (entity) => {
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 Function to cancel subscription
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
- const query = buildQuery(resolvedPath, filter, orderBy, order, startAfter as unknown[] | undefined, limit, databaseId);
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
  /**