@rebasepro/client-firebase 0.0.1-canary.09e5ec5
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/LICENSE +21 -0
- package/README.md +4 -0
- package/dist/components/FirebaseLoginView.d.ts +72 -0
- package/dist/components/RebaseFirebaseApp.d.ts +19 -0
- package/dist/components/RebaseFirebaseAppProps.d.ts +144 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/social_icons.d.ts +6 -0
- package/dist/hooks/index.d.ts +8 -0
- package/dist/hooks/useAppCheck.d.ts +20 -0
- package/dist/hooks/useBuildUserManagement.d.ts +46 -0
- package/dist/hooks/useFirebaseAuthController.d.ts +15 -0
- package/dist/hooks/useFirebaseRealTimeDBDelegate.d.ts +5 -0
- package/dist/hooks/useFirebaseStorageSource.d.ts +14 -0
- package/dist/hooks/useFirestoreDriver.d.ts +56 -0
- package/dist/hooks/useInitialiseFirebase.d.ts +34 -0
- package/dist/hooks/useRecaptcha.d.ts +8 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.es.js +3060 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +3043 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/social_icons.d.ts +6 -0
- package/dist/types/appcheck.d.ts +10 -0
- package/dist/types/auth.d.ts +41 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/text_search.d.ts +39 -0
- package/dist/utils/algolia.d.ts +9 -0
- package/dist/utils/collections_firestore.d.ts +5 -0
- package/dist/utils/database.d.ts +2 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/local_text_search_controller.d.ts +2 -0
- package/dist/utils/pinecone.d.ts +24 -0
- package/dist/utils/rebase_search_controller.d.ts +73 -0
- package/dist/utils/text_search_controller.d.ts +13 -0
- package/package.json +63 -0
- package/src/components/FirebaseLoginView.tsx +693 -0
- package/src/components/RebaseFirebaseApp.tsx +291 -0
- package/src/components/RebaseFirebaseAppProps.tsx +180 -0
- package/src/components/index.ts +3 -0
- package/src/components/social_icons.tsx +135 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useAppCheck.ts +101 -0
- package/src/hooks/useBuildUserManagement.tsx +374 -0
- package/src/hooks/useFirebaseAuthController.ts +334 -0
- package/src/hooks/useFirebaseRealTimeDBDelegate.ts +269 -0
- package/src/hooks/useFirebaseStorageSource.ts +207 -0
- package/src/hooks/useFirestoreDriver.ts +784 -0
- package/src/hooks/useInitialiseFirebase.ts +132 -0
- package/src/hooks/useRecaptcha.tsx +28 -0
- package/src/index.ts +4 -0
- package/src/social_icons.tsx +135 -0
- package/src/types/appcheck.ts +11 -0
- package/src/types/auth.tsx +74 -0
- package/src/types/index.ts +3 -0
- package/src/types/text_search.ts +42 -0
- package/src/utils/algolia.ts +27 -0
- package/src/utils/collections_firestore.ts +148 -0
- package/src/utils/database.ts +39 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/local_text_search_controller.ts +143 -0
- package/src/utils/pinecone.ts +75 -0
- package/src/utils/rebase_search_controller.ts +357 -0
- package/src/utils/text_search_controller.ts +34 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { collection, getFirestore, onSnapshot, query } from "@firebase/firestore";
|
|
2
|
+
import Fuse from "fuse.js"
|
|
3
|
+
|
|
4
|
+
import { FirebaseApp } from "@firebase/app";
|
|
5
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
6
|
+
import { FirestoreTextSearchController, FirestoreTextSearchControllerBuilder } from "../types";
|
|
7
|
+
|
|
8
|
+
const MAX_SEARCH_RESULTS = 80;
|
|
9
|
+
|
|
10
|
+
export const localSearchControllerBuilder: FirestoreTextSearchControllerBuilder = ({
|
|
11
|
+
firebaseApp
|
|
12
|
+
}: {
|
|
13
|
+
|
|
14
|
+
firebaseApp: FirebaseApp,
|
|
15
|
+
}): FirestoreTextSearchController => {
|
|
16
|
+
|
|
17
|
+
let currentPath: string | undefined;
|
|
18
|
+
const indexes: Record<string, Fuse<object & { id: string }>> = {};
|
|
19
|
+
const listeners: Record<string, () => void> = {};
|
|
20
|
+
|
|
21
|
+
const destroyListener = (path: string) => {
|
|
22
|
+
if (listeners[path]) {
|
|
23
|
+
listeners[path]();
|
|
24
|
+
delete listeners[path];
|
|
25
|
+
delete indexes[path];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const init = ({
|
|
30
|
+
path,
|
|
31
|
+
collection: collectionProp,
|
|
32
|
+
databaseId
|
|
33
|
+
}: {
|
|
34
|
+
path: string,
|
|
35
|
+
collection?: EntityCollection,
|
|
36
|
+
databaseId?: string
|
|
37
|
+
}): Promise<boolean> => {
|
|
38
|
+
|
|
39
|
+
if (currentPath && path !== currentPath) {
|
|
40
|
+
destroyListener(currentPath)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
currentPath = path;
|
|
44
|
+
|
|
45
|
+
return new Promise(async (resolve, reject) => {
|
|
46
|
+
if (collectionProp) {
|
|
47
|
+
console.debug("Init local search controller", path);
|
|
48
|
+
const firestore = databaseId ? getFirestore(firebaseApp, databaseId) : getFirestore(firebaseApp);
|
|
49
|
+
const col = collection(firestore, path);
|
|
50
|
+
listeners[path] = onSnapshot(query(col),
|
|
51
|
+
{
|
|
52
|
+
next: (snapshot) => {
|
|
53
|
+
if (snapshot.metadata.fromCache && snapshot.metadata.hasPendingWrites) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const docs = snapshot.docs.map(doc => ({
|
|
57
|
+
id: doc.id,
|
|
58
|
+
...doc.data()
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
indexes[path] = buildIndex(docs, collectionProp);
|
|
62
|
+
console.debug("Added docs to index", path, docs.length);
|
|
63
|
+
resolve(true);
|
|
64
|
+
},
|
|
65
|
+
error: (e) => {
|
|
66
|
+
console.error("Error initializing local search controller", path);
|
|
67
|
+
console.error(e);
|
|
68
|
+
reject(e);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const search = async ({
|
|
77
|
+
searchString,
|
|
78
|
+
path
|
|
79
|
+
}: {
|
|
80
|
+
searchString: string,
|
|
81
|
+
path: string,
|
|
82
|
+
databaseId?: string
|
|
83
|
+
}) => {
|
|
84
|
+
console.debug("Searching local index", path, searchString);
|
|
85
|
+
const index = indexes[path];
|
|
86
|
+
if (!index) {
|
|
87
|
+
throw new Error(`Index not found for path ${path}`);
|
|
88
|
+
}
|
|
89
|
+
let searchResult = index.search(searchString);
|
|
90
|
+
searchResult = searchResult.splice(0, MAX_SEARCH_RESULTS);
|
|
91
|
+
searchResult = searchResult.sort((a, b) => {
|
|
92
|
+
// Check if item A is an exact match
|
|
93
|
+
const aExactMatch = a.item.id === searchString;
|
|
94
|
+
// Check if item B is an exact match
|
|
95
|
+
const bExactMatch = b.item.id === searchString;
|
|
96
|
+
|
|
97
|
+
if (aExactMatch && !bExactMatch) {
|
|
98
|
+
return -1; // Prioritize item A
|
|
99
|
+
} else if (!aExactMatch && bExactMatch) {
|
|
100
|
+
return 1; // Prioritize item B
|
|
101
|
+
} else {
|
|
102
|
+
// If both are exact matches or both are not, sort by Fuse's original score
|
|
103
|
+
return (a.score ?? 0) - (b.score ?? 0);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return searchResult.map((e: any) => e.item.id);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
init,
|
|
111
|
+
search
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildIndex(list: (object & { id: string })[], collection: EntityCollection) {
|
|
116
|
+
|
|
117
|
+
const keys = ["id", ...Object.keys(collection.properties)];
|
|
118
|
+
|
|
119
|
+
const fuseOptions = {
|
|
120
|
+
// isCaseSensitive: false,
|
|
121
|
+
// includeScore: false,
|
|
122
|
+
// shouldSort: true,
|
|
123
|
+
// includeMatches: false,
|
|
124
|
+
// findAllMatches: false,
|
|
125
|
+
// minMatchCharLength: 1,
|
|
126
|
+
// location: 0,
|
|
127
|
+
threshold: 0.6,
|
|
128
|
+
// distance: 100,
|
|
129
|
+
// useExtendedSearch: false,
|
|
130
|
+
// ignoreLocation: false,
|
|
131
|
+
// ignoreFieldNorm: false,
|
|
132
|
+
// fieldNormWeight: 1,
|
|
133
|
+
includeScore: true,
|
|
134
|
+
keys: [{
|
|
135
|
+
name: "title",
|
|
136
|
+
weight: 1.0
|
|
137
|
+
}, ...keys.map(key => ({
|
|
138
|
+
name: key,
|
|
139
|
+
weight: 0.5
|
|
140
|
+
}))]
|
|
141
|
+
};
|
|
142
|
+
return new Fuse<object & { id: string }>(list, fuseOptions);
|
|
143
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { User as FirebaseUser } from "@firebase/auth";
|
|
2
|
+
import { FirestoreTextSearchController, FirestoreTextSearchControllerBuilder } from "../types";
|
|
3
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_SERVER = "https://api.rebase.pro";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Utility function to perform a text search in an algolia index,
|
|
9
|
+
* returning the ids of the entities.
|
|
10
|
+
* @param index
|
|
11
|
+
* @param query
|
|
12
|
+
* @group Firebase
|
|
13
|
+
*/
|
|
14
|
+
export async function performPineconeTextSearch({
|
|
15
|
+
host = DEFAULT_SERVER,
|
|
16
|
+
firebaseToken,
|
|
17
|
+
projectId,
|
|
18
|
+
collectionPath,
|
|
19
|
+
query
|
|
20
|
+
}: {
|
|
21
|
+
host?: string,
|
|
22
|
+
firebaseToken: string,
|
|
23
|
+
collectionPath: string,
|
|
24
|
+
projectId: string,
|
|
25
|
+
query: string
|
|
26
|
+
}): Promise<readonly string[]> {
|
|
27
|
+
|
|
28
|
+
console.debug("Performing Pinecone query", collectionPath, query);
|
|
29
|
+
const response = await fetch((host ?? DEFAULT_SERVER) + `/projects/${projectId}/search/${collectionPath}`,
|
|
30
|
+
{
|
|
31
|
+
// mode: "no-cors",
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Authorization: `Basic ${firebaseToken}`
|
|
36
|
+
// "x-de-version": version
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({
|
|
39
|
+
query
|
|
40
|
+
})
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const promise = await response.json();
|
|
44
|
+
return promise.data.ids;
|
|
45
|
+
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildPineconeSearchController({
|
|
49
|
+
isPathSupported,
|
|
50
|
+
search
|
|
51
|
+
}: {
|
|
52
|
+
isPathSupported: (path: string) => boolean,
|
|
53
|
+
search: (props: {
|
|
54
|
+
searchString: string,
|
|
55
|
+
path: string,
|
|
56
|
+
currentUser?: FirebaseUser
|
|
57
|
+
}) => Promise<readonly string[] | undefined>,
|
|
58
|
+
}): FirestoreTextSearchControllerBuilder {
|
|
59
|
+
return (props): FirestoreTextSearchController => {
|
|
60
|
+
|
|
61
|
+
const init = (props: {
|
|
62
|
+
path: string,
|
|
63
|
+
collection?: EntityCollection
|
|
64
|
+
}) => {
|
|
65
|
+
// do nothing
|
|
66
|
+
return Promise.resolve(isPathSupported(props.path));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
init,
|
|
71
|
+
search
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { FirestoreTextSearchController, FirestoreTextSearchControllerBuilder } from "../types";
|
|
2
|
+
import { FirebaseApp } from "@firebase/app";
|
|
3
|
+
import { getFunctions, httpsCallable } from "@firebase/functions";
|
|
4
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configuration returned by the Rebase Search Extension
|
|
8
|
+
*/
|
|
9
|
+
interface SearchConfig {
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
protocol: "http" | "https";
|
|
13
|
+
apiKey: string;
|
|
14
|
+
collectionsToIndex: string[];
|
|
15
|
+
path?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for building the Rebase Search Controller
|
|
19
|
+
*/
|
|
20
|
+
export interface RebaseSearchControllerOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The Firebase region where the extension is deployed.
|
|
23
|
+
*/
|
|
24
|
+
region: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The extension instance ID. Defaults to "rebase-search".
|
|
28
|
+
* Use this if you installed the extension with a custom instance ID.
|
|
29
|
+
*/
|
|
30
|
+
extensionInstanceId?: string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Custom Typesense configuration. If provided, skips fetching from extension.
|
|
34
|
+
* Use this if you want to connect to your own Typesense instance.
|
|
35
|
+
*/
|
|
36
|
+
customConfig?: {
|
|
37
|
+
host: string;
|
|
38
|
+
port?: number;
|
|
39
|
+
protocol?: "http" | "https";
|
|
40
|
+
apiKey: string;
|
|
41
|
+
path?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Override the collections to index returned by the extension.
|
|
46
|
+
* Use this if you want to restrict search to specific collections on the client side,
|
|
47
|
+
* regardless of what is configured in the extension.
|
|
48
|
+
*/
|
|
49
|
+
collections?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a text search controller that uses the Rebase Search Extension.
|
|
54
|
+
*
|
|
55
|
+
* This requires the `rebase-search` extension to be installed in the user's
|
|
56
|
+
* Firebase project. The extension automatically deploys Typesense to Cloud Run
|
|
57
|
+
* and syncs Firestore data.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* import { buildRebaseSearchController } from "@rebasepro/client-firebase";
|
|
62
|
+
*
|
|
63
|
+
* // Using the extension (recommended)
|
|
64
|
+
* const textSearchControllerBuilder = buildRebaseSearchController();
|
|
65
|
+
*
|
|
66
|
+
* // Or with custom Typesense instance
|
|
67
|
+
* const textSearchControllerBuilder = buildRebaseSearchController({
|
|
68
|
+
* customConfig: {
|
|
69
|
+
* host: "your-typesense-instance.com",
|
|
70
|
+
* apiKey: "your-api-key"
|
|
71
|
+
* }
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* <RebaseApp
|
|
75
|
+
* textSearchControllerBuilder={textSearchControllerBuilder}
|
|
76
|
+
* collections={[
|
|
77
|
+
* {
|
|
78
|
+
* path: "products",
|
|
79
|
+
* name: "Products",
|
|
80
|
+
* textSearchEnabled: true, // Enable search for this collection
|
|
81
|
+
* properties: { ... }
|
|
82
|
+
* }
|
|
83
|
+
* ]}
|
|
84
|
+
* />
|
|
85
|
+
* ```
|
|
86
|
+
*
|
|
87
|
+
* @param options - Configuration options
|
|
88
|
+
* @returns A FirestoreTextSearchControllerBuilder
|
|
89
|
+
*
|
|
90
|
+
* @group Firebase
|
|
91
|
+
*/
|
|
92
|
+
export function buildRebaseSearchController(
|
|
93
|
+
options?: RebaseSearchControllerOptions
|
|
94
|
+
): FirestoreTextSearchControllerBuilder {
|
|
95
|
+
const region = options?.region || "us-central1";
|
|
96
|
+
const extensionInstanceId = options?.extensionInstanceId || "typesense-search";
|
|
97
|
+
|
|
98
|
+
let searchConfig: SearchConfig | null = null;
|
|
99
|
+
let typesenseClient: any = null;
|
|
100
|
+
let initPromise: Promise<void> | null = null;
|
|
101
|
+
|
|
102
|
+
return ({ firebaseApp }: { firebaseApp: FirebaseApp }): FirestoreTextSearchController => {
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Initializes the Typesense client
|
|
106
|
+
*/
|
|
107
|
+
const initializeClient = async (): Promise<void> => {
|
|
108
|
+
if (typesenseClient) return;
|
|
109
|
+
|
|
110
|
+
// Use custom config if provided
|
|
111
|
+
if (options?.customConfig) {
|
|
112
|
+
searchConfig = {
|
|
113
|
+
host: options.customConfig.host,
|
|
114
|
+
port: options.customConfig.port || 443,
|
|
115
|
+
protocol: options.customConfig.protocol || "https",
|
|
116
|
+
apiKey: options.customConfig.apiKey,
|
|
117
|
+
path: options.customConfig.path,
|
|
118
|
+
collectionsToIndex: ["*"]
|
|
119
|
+
};
|
|
120
|
+
} else {
|
|
121
|
+
// Fetch config from extension
|
|
122
|
+
const functions = getFunctions(firebaseApp, region);
|
|
123
|
+
const getConfig = httpsCallable<void, SearchConfig>(
|
|
124
|
+
functions,
|
|
125
|
+
`ext-${extensionInstanceId}-getSearchConfig`
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await getConfig();
|
|
130
|
+
searchConfig = result.data;
|
|
131
|
+
if (options?.collections && options.collections.length > 0) {
|
|
132
|
+
searchConfig.collectionsToIndex = options.collections;
|
|
133
|
+
}
|
|
134
|
+
} catch (error: any) {
|
|
135
|
+
console.error("Failed to get search config from extension:", error);
|
|
136
|
+
throw new Error(
|
|
137
|
+
"Failed to initialize Rebase Search. " +
|
|
138
|
+
"Make sure the rebase-search extension is installed and configured. " +
|
|
139
|
+
`Error: ${error.message || error}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!searchConfig) {
|
|
145
|
+
throw new Error("Search config not available");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Dynamically import Typesense client to avoid bundling if not used
|
|
149
|
+
const Typesense = (await import("typesense")).default;
|
|
150
|
+
|
|
151
|
+
typesenseClient = new Typesense.Client({
|
|
152
|
+
nodes: [{
|
|
153
|
+
host: searchConfig.host,
|
|
154
|
+
port: searchConfig.port,
|
|
155
|
+
protocol: searchConfig.protocol,
|
|
156
|
+
path: searchConfig.path || ""
|
|
157
|
+
}],
|
|
158
|
+
apiKey: searchConfig.apiKey,
|
|
159
|
+
connectionTimeoutSeconds: 5,
|
|
160
|
+
retryIntervalSeconds: 0.5,
|
|
161
|
+
numRetries: 2
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Converts a Firestore path to Typesense collection name
|
|
167
|
+
* e.g., "users/123/orders" -> "users_orders"
|
|
168
|
+
*/
|
|
169
|
+
const getTypesenseCollectionName = (path: string): string => {
|
|
170
|
+
const pathParts = path.split("/");
|
|
171
|
+
// Extract collection names (even indices) and join with underscore
|
|
172
|
+
const collectionNames: string[] = [];
|
|
173
|
+
for (let i = 0; i < pathParts.length; i += 2) {
|
|
174
|
+
if (pathParts[i]) {
|
|
175
|
+
collectionNames.push(pathParts[i]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return collectionNames.join("_");
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Extracts parent filter for subcollection queries
|
|
183
|
+
* e.g., "users/123/orders" -> { "_parent_users_id": "123" }
|
|
184
|
+
*/
|
|
185
|
+
const getParentFilter = (path: string): string | null => {
|
|
186
|
+
const pathParts = path.split("/");
|
|
187
|
+
if (pathParts.length <= 1) return null;
|
|
188
|
+
|
|
189
|
+
// Build filter for parent IDs
|
|
190
|
+
const filters: string[] = [];
|
|
191
|
+
for (let i = 0; i < pathParts.length - 1; i += 2) {
|
|
192
|
+
const collectionName = pathParts[i];
|
|
193
|
+
const docId = pathParts[i + 1];
|
|
194
|
+
if (collectionName && docId) {
|
|
195
|
+
filters.push(`_parent_${collectionName}_id:=${docId}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return filters.length > 0 ? filters.join(" && ") : null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Initializes search for a specific collection path
|
|
204
|
+
*/
|
|
205
|
+
const init = async (props: {
|
|
206
|
+
path: string;
|
|
207
|
+
collection?: EntityCollection;
|
|
208
|
+
databaseId?: string;
|
|
209
|
+
}): Promise<boolean> => {
|
|
210
|
+
try {
|
|
211
|
+
// Ensure client is initialized (only once)
|
|
212
|
+
if (!initPromise) {
|
|
213
|
+
initPromise = initializeClient();
|
|
214
|
+
}
|
|
215
|
+
await initPromise;
|
|
216
|
+
|
|
217
|
+
if (!searchConfig) return false;
|
|
218
|
+
|
|
219
|
+
// Get collection pattern (e.g., "users/orders" from "users/123/orders")
|
|
220
|
+
const pathParts = props.path.split("/");
|
|
221
|
+
const collectionNames: string[] = [];
|
|
222
|
+
for (let i = 0; i < pathParts.length; i += 2) {
|
|
223
|
+
if (pathParts[i]) collectionNames.push(pathParts[i]);
|
|
224
|
+
}
|
|
225
|
+
const collectionPattern = collectionNames.join("/");
|
|
226
|
+
const rootCollection = collectionNames[0];
|
|
227
|
+
|
|
228
|
+
// Check if this collection is indexed
|
|
229
|
+
if (searchConfig.collectionsToIndex.includes("*")) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check exact pattern or root collection
|
|
234
|
+
return searchConfig.collectionsToIndex.includes(collectionPattern) ||
|
|
235
|
+
searchConfig.collectionsToIndex.includes(rootCollection);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.error("Failed to initialize Rebase Search:", error);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Cache for Typesense collection schemas (field names)
|
|
243
|
+
const schemaCache: Map<string, string[]> = new Map();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Fetches the Typesense collection schema and returns searchable string field names.
|
|
247
|
+
* Results are cached to avoid repeated API calls.
|
|
248
|
+
*/
|
|
249
|
+
const getSearchableFieldsFromSchema = async (collectionName: string): Promise<string[]> => {
|
|
250
|
+
// Check cache first
|
|
251
|
+
if (schemaCache.has(collectionName)) {
|
|
252
|
+
return schemaCache.get(collectionName)!;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const collection = await typesenseClient.collections(collectionName).retrieve();
|
|
257
|
+
|
|
258
|
+
// Extract string fields from the schema
|
|
259
|
+
const stringFields = collection.fields
|
|
260
|
+
.filter((f: any) => {
|
|
261
|
+
// Include string and string[] types, exclude internal fields
|
|
262
|
+
const isStringType = f.type === "string" ||
|
|
263
|
+
f.type === "string[]" ||
|
|
264
|
+
f.type === "string*" ||
|
|
265
|
+
f.type === "auto";
|
|
266
|
+
const isNotInternal = !f.name.startsWith("_") && f.name !== ".*";
|
|
267
|
+
return isStringType && isNotInternal;
|
|
268
|
+
})
|
|
269
|
+
.map((f: any) => f.name);
|
|
270
|
+
|
|
271
|
+
schemaCache.set(collectionName, stringFields);
|
|
272
|
+
return stringFields;
|
|
273
|
+
} catch (error: any) {
|
|
274
|
+
if (error.httpStatus === 404) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Collection "${collectionName}" not found in Typesense. ` +
|
|
277
|
+
"Make sure the collection has been indexed. Try running the backfill function."
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Performs a search and returns document IDs
|
|
286
|
+
* Supports subcollections by filtering on parent IDs
|
|
287
|
+
*/
|
|
288
|
+
const search = async (props: {
|
|
289
|
+
searchString: string;
|
|
290
|
+
path: string;
|
|
291
|
+
databaseId?: string;
|
|
292
|
+
collection?: EntityCollection;
|
|
293
|
+
}): Promise<readonly string[] | undefined> => {
|
|
294
|
+
if (!typesenseClient) {
|
|
295
|
+
// Ensure client is initialized
|
|
296
|
+
if (!initPromise) {
|
|
297
|
+
initPromise = initializeClient();
|
|
298
|
+
}
|
|
299
|
+
await initPromise;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!typesenseClient) {
|
|
303
|
+
throw new Error("Typesense client not initialized. Check extension configuration.");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Convert path to Typesense collection name
|
|
307
|
+
const collectionName = getTypesenseCollectionName(props.path);
|
|
308
|
+
|
|
309
|
+
// Get parent filter for subcollections
|
|
310
|
+
const parentFilter = getParentFilter(props.path);
|
|
311
|
+
|
|
312
|
+
// Get searchable fields from the actual Typesense schema
|
|
313
|
+
const searchableFields = await getSearchableFieldsFromSchema(collectionName);
|
|
314
|
+
|
|
315
|
+
if (searchableFields.length === 0) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`No searchable string fields found in Typesense collection "${collectionName}". ` +
|
|
318
|
+
"Make sure some documents have been indexed with string fields."
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const queryBy = searchableFields.join(",");
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const searchParams: any = {
|
|
326
|
+
q: props.searchString,
|
|
327
|
+
query_by: queryBy,
|
|
328
|
+
per_page: 100,
|
|
329
|
+
prefix: true, // Enable prefix matching
|
|
330
|
+
typo_tokens_threshold: 1 // Allow some typos
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Add filter for subcollection queries
|
|
334
|
+
if (parentFilter) {
|
|
335
|
+
searchParams.filter_by = parentFilter;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = await typesenseClient
|
|
339
|
+
.collections(collectionName)
|
|
340
|
+
.documents()
|
|
341
|
+
.search(searchParams);
|
|
342
|
+
|
|
343
|
+
// Extract document IDs from hits
|
|
344
|
+
const ids = result.hits?.map((hit: any) => hit.document.id) ?? [];
|
|
345
|
+
|
|
346
|
+
return ids as readonly string[];
|
|
347
|
+
} catch (error: any) {
|
|
348
|
+
// Parse error message for user-friendly display
|
|
349
|
+
const message = error.message || error.toString();
|
|
350
|
+
throw new Error(`Search failed: ${message}`);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return { init,
|
|
355
|
+
search };
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FirestoreTextSearchController, FirestoreTextSearchControllerBuilder } from "../types";
|
|
2
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Utility function to perform a text search in an external index,
|
|
6
|
+
* returning the ids of the entities.
|
|
7
|
+
* @group Firebase
|
|
8
|
+
*/
|
|
9
|
+
export function buildExternalSearchController({
|
|
10
|
+
isPathSupported,
|
|
11
|
+
search
|
|
12
|
+
}: {
|
|
13
|
+
isPathSupported: (path: string) => boolean,
|
|
14
|
+
search: (props: {
|
|
15
|
+
searchString: string,
|
|
16
|
+
path: string
|
|
17
|
+
}) => Promise<readonly string[] | undefined>,
|
|
18
|
+
}): FirestoreTextSearchControllerBuilder {
|
|
19
|
+
return (props): FirestoreTextSearchController => {
|
|
20
|
+
|
|
21
|
+
const init = (props: {
|
|
22
|
+
path: string,
|
|
23
|
+
collection?: EntityCollection
|
|
24
|
+
}) => {
|
|
25
|
+
return Promise.resolve(isPathSupported(props.path));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
init,
|
|
30
|
+
search
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
}
|