firestore-meilisearch 0.1.0
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/.editorconfig +20 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +26 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
- package/.github/ISSUE_TEMPLATE/other.md +7 -0
- package/.github/dependatbot.yml +23 -0
- package/.github/release-draft-template.yml +33 -0
- package/.github/scripts/check-release.sh +42 -0
- package/.github/workflows/publish.yml +30 -0
- package/.github/workflows/release-drafter.yml +16 -0
- package/.github/workflows/test.yml +42 -0
- package/CHANGELOG.md +3 -0
- package/CONTRIBUTING.md +236 -0
- package/LICENSE +201 -0
- package/POSTINSTALL.md +40 -0
- package/PREINSTALL.md +42 -0
- package/README.md +128 -0
- package/bors.toml +8 -0
- package/dataset/firebase-export-metadata.json +8 -0
- package/dataset/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata +0 -0
- package/dataset/firestore_export/all_namespaces/all_kinds/output-0 +0 -0
- package/dataset/firestore_export/firestore_export.overall_export_metadata +0 -0
- package/extension.yaml +176 -0
- package/firebase.json +20 -0
- package/functions/.eslintignore +2 -0
- package/functions/.eslintrc.js +54 -0
- package/functions/__tests__/__mocks__/console.ts +7 -0
- package/functions/__tests__/adapter.test.ts +98 -0
- package/functions/__tests__/config.test.ts +130 -0
- package/functions/__tests__/data/document.ts +11 -0
- package/functions/__tests__/data/environment.ts +9 -0
- package/functions/__tests__/functions.test.ts +280 -0
- package/functions/__tests__/jest.setup.ts +1 -0
- package/functions/__tests__/test.types.d.ts +5 -0
- package/functions/__tests__/tsconfig.json +5 -0
- package/functions/__tests__/util.test.ts +200 -0
- package/functions/jest.config.js +12 -0
- package/functions/lib/adapter.js +61 -0
- package/functions/lib/config.js +13 -0
- package/functions/lib/import/config.js +127 -0
- package/functions/lib/import/index.js +93 -0
- package/functions/lib/index.js +90 -0
- package/functions/lib/logs.js +97 -0
- package/functions/lib/meilisearch/create-index.js +17 -0
- package/functions/lib/meilisearch-index.js +17 -0
- package/functions/lib/types.js +2 -0
- package/functions/lib/util.js +47 -0
- package/functions/lib/version.js +4 -0
- package/functions/package.json +53 -0
- package/functions/src/adapter.ts +106 -0
- package/functions/src/config.ts +34 -0
- package/functions/src/import/config.ts +207 -0
- package/functions/src/import/index.ts +115 -0
- package/functions/src/index.ts +103 -0
- package/functions/src/logs.ts +107 -0
- package/functions/src/meilisearch/create-index.ts +20 -0
- package/functions/src/types.ts +8 -0
- package/functions/src/util.ts +63 -0
- package/functions/src/version.ts +1 -0
- package/functions/tsconfig.eslint.json +13 -0
- package/functions/tsconfig.json +23 -0
- package/functions/yarn.lock +5306 -0
- package/guides/IMPORT_EXISTING_DOCUMENTS.md +74 -0
- package/package.json +21 -0
- package/script/version.sh +51 -0
- package/test-params-example.env +9 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.initMeilisearchIndex = void 0;
|
|
4
|
+
const meilisearch_1 = require("meilisearch");
|
|
5
|
+
/**
|
|
6
|
+
* initMeilisearchIndex
|
|
7
|
+
* @param {MeilisearchConfig} - Meilisearch configuration
|
|
8
|
+
* @return {Index}
|
|
9
|
+
*/
|
|
10
|
+
function initMeilisearchIndex({ host, apiKey, indexUid, }) {
|
|
11
|
+
const client = new meilisearch_1.MeiliSearch({
|
|
12
|
+
host,
|
|
13
|
+
apiKey,
|
|
14
|
+
});
|
|
15
|
+
return client.index(indexUid);
|
|
16
|
+
}
|
|
17
|
+
exports.initMeilisearchIndex = initMeilisearchIndex;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createMeiliSearchIndex = void 0;
|
|
4
|
+
const meilisearch_1 = require("meilisearch");
|
|
5
|
+
/**
|
|
6
|
+
* createMeiliSearchIndex
|
|
7
|
+
* @param {MeiliSearchConfig}
|
|
8
|
+
* @return {Index}
|
|
9
|
+
*/
|
|
10
|
+
function createMeiliSearchIndex({ host, apiKey, indexUid, }) {
|
|
11
|
+
const client = new meilisearch_1.MeiliSearch({
|
|
12
|
+
host,
|
|
13
|
+
apiKey,
|
|
14
|
+
});
|
|
15
|
+
return client.index(indexUid);
|
|
16
|
+
}
|
|
17
|
+
exports.createMeiliSearchIndex = createMeiliSearchIndex;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getFieldsToIndex = exports.getChangedDocumentId = exports.getChangeType = exports.ChangeType = void 0;
|
|
4
|
+
const config_1 = require("./config");
|
|
5
|
+
var ChangeType;
|
|
6
|
+
(function (ChangeType) {
|
|
7
|
+
ChangeType[ChangeType["CREATE"] = 0] = "CREATE";
|
|
8
|
+
ChangeType[ChangeType["DELETE"] = 1] = "DELETE";
|
|
9
|
+
ChangeType[ChangeType["UPDATE"] = 2] = "UPDATE";
|
|
10
|
+
})(ChangeType = exports.ChangeType || (exports.ChangeType = {}));
|
|
11
|
+
/**
|
|
12
|
+
* Get type of the modification perform on a document.
|
|
13
|
+
* @param {Change<T>} change The Functions interface for events that change state.
|
|
14
|
+
* @return {ChangeType} Final type of the event.
|
|
15
|
+
*/
|
|
16
|
+
function getChangeType(change) {
|
|
17
|
+
if (!change.after.exists) {
|
|
18
|
+
return ChangeType.DELETE;
|
|
19
|
+
}
|
|
20
|
+
if (!change.before.exists) {
|
|
21
|
+
return ChangeType.CREATE;
|
|
22
|
+
}
|
|
23
|
+
return ChangeType.UPDATE;
|
|
24
|
+
}
|
|
25
|
+
exports.getChangeType = getChangeType;
|
|
26
|
+
/**
|
|
27
|
+
* Get final id of a document after modification.
|
|
28
|
+
* @param {Change<T>} change The Functions interface for events that change state.
|
|
29
|
+
* @return {string} Final state type of the event.
|
|
30
|
+
*/
|
|
31
|
+
function getChangedDocumentId(change) {
|
|
32
|
+
if (change.after.exists) {
|
|
33
|
+
return change.after.id;
|
|
34
|
+
}
|
|
35
|
+
return change.before.id;
|
|
36
|
+
}
|
|
37
|
+
exports.getChangedDocumentId = getChangedDocumentId;
|
|
38
|
+
/**
|
|
39
|
+
* Returns the MEILISEARCH_FIELDS_TO_INDEX value from the config file and formats it.
|
|
40
|
+
* @return {string[]} An array of fields.
|
|
41
|
+
*/
|
|
42
|
+
function getFieldsToIndex() {
|
|
43
|
+
return config_1.config.meilisearch.fieldsToIndex
|
|
44
|
+
? config_1.config.meilisearch.fieldsToIndex.split(/[ ,]+/)
|
|
45
|
+
: [];
|
|
46
|
+
}
|
|
47
|
+
exports.getFieldsToIndex = getFieldsToIndex;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "firestore-meilisearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"lint": "eslint .",
|
|
6
|
+
"lint:fix": "eslint . --fix",
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"watch": "tsc --watch",
|
|
9
|
+
"playground": "nodemon ./lib/import --project 521120192778 --source-collection-path movies --index movies --batch-size 300 --non-interactive -H 'http://localhost:7700' -a masterKey",
|
|
10
|
+
"serve": "yarn build && firebase emulators:start --only functions",
|
|
11
|
+
"shell": "yarn build && firebase functions:shell",
|
|
12
|
+
"start": "yarn shell",
|
|
13
|
+
"deploy": "firebase deploy --only functions",
|
|
14
|
+
"logs": "firebase functions:log",
|
|
15
|
+
"test": "jest",
|
|
16
|
+
"test:watch": "jest --watch",
|
|
17
|
+
"test:coverage": "jest --coverage"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=14.0.0"
|
|
21
|
+
},
|
|
22
|
+
"main": "lib/index.js",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"firebase-admin": "^10.0.2",
|
|
25
|
+
"firebase-functions": "^3.16.0",
|
|
26
|
+
"meilisearch": "^0.25.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@babel/preset-typescript": "^7.15.0",
|
|
30
|
+
"@types/jest": "^27.0.2",
|
|
31
|
+
"@typescript-eslint/eslint-plugin": "^4.32.0",
|
|
32
|
+
"@typescript-eslint/parser": "^4.32.0",
|
|
33
|
+
"commander": "^8.3.0",
|
|
34
|
+
"eslint": "^7.6.0",
|
|
35
|
+
"eslint-config-google": "^0.14.0",
|
|
36
|
+
"eslint-config-prettier": "^8.3.0",
|
|
37
|
+
"eslint-plugin-import": "^2.24.2",
|
|
38
|
+
"eslint-plugin-jest": "^24.4.2",
|
|
39
|
+
"eslint-plugin-prettier": "^4.0.0",
|
|
40
|
+
"firebase-functions-test": "^0.3.2",
|
|
41
|
+
"inquirer": "^8.2.0",
|
|
42
|
+
"jest": "^27.2.2",
|
|
43
|
+
"jest-mock": "^27.1.1",
|
|
44
|
+
"js-yaml": "^4.1.0",
|
|
45
|
+
"mocked-env": "^1.3.5",
|
|
46
|
+
"prettier": "^2.4.1",
|
|
47
|
+
"ts-jest": "^27.0.5",
|
|
48
|
+
"ts-node": "^10.2.1",
|
|
49
|
+
"typescript": "^4.4.3"
|
|
50
|
+
},
|
|
51
|
+
"private": true,
|
|
52
|
+
"bin": "lib/import/index.js"
|
|
53
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/*
|
|
3
|
+
* Copyright 2022 Meilisearch
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { DocumentSnapshot } from 'firebase-functions/lib/providers/firestore'
|
|
19
|
+
import {
|
|
20
|
+
DocumentReference,
|
|
21
|
+
GeoPoint,
|
|
22
|
+
Timestamp,
|
|
23
|
+
} from 'firebase-admin/lib/firestore'
|
|
24
|
+
import { getFieldsToIndex } from './util'
|
|
25
|
+
import * as logs from './logs'
|
|
26
|
+
|
|
27
|
+
type MeilisearchGeoPoint = {
|
|
28
|
+
lat: number
|
|
29
|
+
lng: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type FirestoreRow =
|
|
33
|
+
| null
|
|
34
|
+
| boolean
|
|
35
|
+
| number
|
|
36
|
+
| string
|
|
37
|
+
| DocumentReference
|
|
38
|
+
| GeoPoint
|
|
39
|
+
| Timestamp
|
|
40
|
+
| Array<any>
|
|
41
|
+
| Map<any, any>
|
|
42
|
+
| MeilisearchGeoPoint
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Adapts documents from the Firestore database to Meilisearch compatible documents.
|
|
46
|
+
* @param {string} documentId Document id.
|
|
47
|
+
* @param {DocumentSnapshot} snapshot Snapshot of the data contained in the document read from your Firestore database.
|
|
48
|
+
* @return {Record<string, any>} A properly formatted document to be added or updated in Meilisearch.
|
|
49
|
+
*/
|
|
50
|
+
export function adaptDocument(
|
|
51
|
+
documentId: string,
|
|
52
|
+
snapshot: DocumentSnapshot
|
|
53
|
+
): Record<string, any> {
|
|
54
|
+
const fields = getFieldsToIndex()
|
|
55
|
+
const data = snapshot.data() || {}
|
|
56
|
+
if ('_firestore_id' in data) {
|
|
57
|
+
delete data.id
|
|
58
|
+
}
|
|
59
|
+
if (fields.length === 0) {
|
|
60
|
+
return { _firestore_id: documentId, ...data }
|
|
61
|
+
}
|
|
62
|
+
const document = Object.keys(data).reduce(
|
|
63
|
+
(acc, key) => {
|
|
64
|
+
if (fields.includes(key)) {
|
|
65
|
+
const [field, value] = adaptValues(key, data[key])
|
|
66
|
+
return { ...acc, [field]: value }
|
|
67
|
+
}
|
|
68
|
+
return acc
|
|
69
|
+
},
|
|
70
|
+
{ _firestore_id: documentId }
|
|
71
|
+
)
|
|
72
|
+
return document
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Checks and adapts each values to be compatible with Meilisearch documents.
|
|
77
|
+
* @param {string} field
|
|
78
|
+
* @param {FirestoreRow} value
|
|
79
|
+
* @return {[string,FirestoreRow]} A properly formatted array of field and value.
|
|
80
|
+
*/
|
|
81
|
+
export function adaptValues(
|
|
82
|
+
field: string,
|
|
83
|
+
value: FirestoreRow
|
|
84
|
+
): [string, FirestoreRow | MeilisearchGeoPoint] {
|
|
85
|
+
if (value instanceof GeoPoint) {
|
|
86
|
+
if (field === '_geo') {
|
|
87
|
+
logs.infoGeoPoint(true)
|
|
88
|
+
return [field, adaptGeoPoint(value)]
|
|
89
|
+
} else {
|
|
90
|
+
logs.infoGeoPoint(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [field, value]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Adapts GeoPoint Firestore instance to fit with Meilisearch geo point.
|
|
98
|
+
* @param {GeoPoint} geoPoint GeoPoint Firestore object.
|
|
99
|
+
* @return {MeilisearchGeoPoint} A properly formatted geo point for Meilisearch.
|
|
100
|
+
*/
|
|
101
|
+
const adaptGeoPoint = (geoPoint: GeoPoint): MeilisearchGeoPoint => {
|
|
102
|
+
return {
|
|
103
|
+
lat: geoPoint.latitude,
|
|
104
|
+
lng: geoPoint.longitude,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/*
|
|
3
|
+
* Copyright 2022 Meilisearch
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { MeilisearchConfig } from './types'
|
|
18
|
+
|
|
19
|
+
type PluginConfiguration = {
|
|
20
|
+
location: string
|
|
21
|
+
collectionPath: string
|
|
22
|
+
meilisearch: MeilisearchConfig
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const config: PluginConfiguration = {
|
|
26
|
+
location: process.env.LOCATION || 'europe-west1',
|
|
27
|
+
collectionPath: process.env.COLLECTION_PATH || '',
|
|
28
|
+
meilisearch: {
|
|
29
|
+
host: process.env.MEILISEARCH_HOST || '',
|
|
30
|
+
apiKey: process.env.MEILISEARCH_API_KEY || '',
|
|
31
|
+
indexUid: process.env.MEILISEARCH_INDEX_NAME || '',
|
|
32
|
+
fieldsToIndex: process.env.MEILISEARCH_FIELDS_TO_INDEX || '',
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import * as inquirer from 'inquirer'
|
|
3
|
+
import { MeilisearchConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
const FIRESTORE_VALID_CHARACTERS = /^[^/]+$/
|
|
6
|
+
const FIRESTORE_COLLECTION_NAME_MAX_CHARS = 6144
|
|
7
|
+
const PROJECT_ID_MAX_CHARS = 30
|
|
8
|
+
const MEILISEARCH_VALID_CHARACTERS = /^[a-zA-Z-_0-9,]*$/
|
|
9
|
+
const MEILISEARCH_UID_MAX_CHARS = 6144
|
|
10
|
+
|
|
11
|
+
// Create a new interface to manage the arguments from the non-interactive command line.
|
|
12
|
+
const program = new Command()
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.name('firestore-meilisearch')
|
|
16
|
+
.option(
|
|
17
|
+
'--non-interactive',
|
|
18
|
+
'Parse all input from command line flags instead of prompting the caller.',
|
|
19
|
+
false
|
|
20
|
+
)
|
|
21
|
+
.option(
|
|
22
|
+
'-P, --project <project>',
|
|
23
|
+
'Firebase Project ID for project containing the Cloud Firestore database.'
|
|
24
|
+
)
|
|
25
|
+
.option(
|
|
26
|
+
'-s, --source-collection-path <source-collection-path>',
|
|
27
|
+
'The path of the Cloud Firestore Collection to import from. (This may, or may not, be the same Collection for which you plan to mirror changes.)'
|
|
28
|
+
)
|
|
29
|
+
.option(
|
|
30
|
+
'-q, --query-collection-group [true|false]',
|
|
31
|
+
"Use 'true' for a collection group query, otherwise a collection query is performed."
|
|
32
|
+
)
|
|
33
|
+
.option(
|
|
34
|
+
'-i, --index <index>',
|
|
35
|
+
"The Uid of the index in Meilisearch to import to. (An index will be created if it doesn't already exist.)"
|
|
36
|
+
)
|
|
37
|
+
.option(
|
|
38
|
+
'-b, --batch-size [batch-size]',
|
|
39
|
+
'Number of documents to stream into Meilisearch at once.',
|
|
40
|
+
value => parseInt(value, 10),
|
|
41
|
+
1000
|
|
42
|
+
)
|
|
43
|
+
.option(
|
|
44
|
+
'-H, --host <host>',
|
|
45
|
+
'The Host of your Meilisearch database. Example: http://localhost:7700.'
|
|
46
|
+
)
|
|
47
|
+
.option(
|
|
48
|
+
'-a, --api-key <api-key>',
|
|
49
|
+
'The Meilisearch API key with permission to perform actions on indexes. Both the private key and the master key are valid choices but we strongly recommend using the private key for security purposes.'
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const validateInput = (
|
|
53
|
+
value: string,
|
|
54
|
+
name: string,
|
|
55
|
+
regex: RegExp,
|
|
56
|
+
sizeLimit: number
|
|
57
|
+
) => {
|
|
58
|
+
if (!value || typeof value !== 'string' || value.trim() === '') {
|
|
59
|
+
return `Please supply a ${name}`
|
|
60
|
+
}
|
|
61
|
+
if (value.trim().length > sizeLimit) {
|
|
62
|
+
return `${name} must be at most ${sizeLimit} characters long`
|
|
63
|
+
}
|
|
64
|
+
if (!value.match(regex)) {
|
|
65
|
+
return `The ${name} does not match the regular expression provided`
|
|
66
|
+
}
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const validateBatchSize = (value: string) => {
|
|
71
|
+
if (/^\d+$/.test(value) == false) {
|
|
72
|
+
return `The batchsize ${value} should be a number`
|
|
73
|
+
}
|
|
74
|
+
return parseInt(value, 10) > 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Questions for the interactive user interface in command line.
|
|
78
|
+
const questions = [
|
|
79
|
+
{
|
|
80
|
+
message: 'What is your Firebase project ID?',
|
|
81
|
+
name: 'project',
|
|
82
|
+
type: 'input',
|
|
83
|
+
validate: value =>
|
|
84
|
+
validateInput(
|
|
85
|
+
value,
|
|
86
|
+
'project ID',
|
|
87
|
+
FIRESTORE_VALID_CHARACTERS,
|
|
88
|
+
PROJECT_ID_MAX_CHARS
|
|
89
|
+
),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
message:
|
|
93
|
+
'What is the path of the Cloud Firestore Collection you would like to import from? ' +
|
|
94
|
+
'(This may, or may not, be the same Collection for which you plan to mirror changes.)',
|
|
95
|
+
name: 'sourceCollectionPath',
|
|
96
|
+
type: 'input',
|
|
97
|
+
validate: value =>
|
|
98
|
+
validateInput(
|
|
99
|
+
value,
|
|
100
|
+
'collection path',
|
|
101
|
+
FIRESTORE_VALID_CHARACTERS,
|
|
102
|
+
FIRESTORE_COLLECTION_NAME_MAX_CHARS
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
message: 'Would you like to import documents via a Collection Group query?',
|
|
107
|
+
name: 'queryCollectionGroup',
|
|
108
|
+
type: 'confirm',
|
|
109
|
+
default: false,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
message:
|
|
113
|
+
"What is the Uid of the Meilisearch index that you would like to use? (A index will be created if it doesn't already exist)",
|
|
114
|
+
name: 'index',
|
|
115
|
+
type: 'input',
|
|
116
|
+
validate: value =>
|
|
117
|
+
validateInput(
|
|
118
|
+
value,
|
|
119
|
+
'index',
|
|
120
|
+
MEILISEARCH_VALID_CHARACTERS,
|
|
121
|
+
MEILISEARCH_UID_MAX_CHARS
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
message:
|
|
126
|
+
'How many documents should the import stream into Meilisearch at once?',
|
|
127
|
+
name: 'batchSize',
|
|
128
|
+
type: 'input',
|
|
129
|
+
default: 1000,
|
|
130
|
+
validate: validateBatchSize,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
message:
|
|
134
|
+
'What is the host of the Meilisearch database that you would like to use? Example: http://localhost:7700.',
|
|
135
|
+
name: 'host',
|
|
136
|
+
type: 'input',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
message:
|
|
140
|
+
'Which Meilisearch API key with permission to perform actions on indexes do you like to use? Both the private key and the master key are valid choices but we strongly recommend using the private key for security purposes.',
|
|
141
|
+
name: 'apiKey',
|
|
142
|
+
type: 'input',
|
|
143
|
+
},
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
export interface CLIConfig {
|
|
147
|
+
projectId: string
|
|
148
|
+
sourceCollectionPath: string
|
|
149
|
+
queryCollectionGroup: boolean
|
|
150
|
+
batchSize: string
|
|
151
|
+
meilisearch: MeilisearchConfig
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse the argument from the interactive or non-interactive command line.
|
|
156
|
+
*/
|
|
157
|
+
export async function parseConfig(): Promise<CLIConfig> {
|
|
158
|
+
program.parse(process.argv)
|
|
159
|
+
|
|
160
|
+
const options = program.opts()
|
|
161
|
+
if (options.nonInteractive) {
|
|
162
|
+
if (
|
|
163
|
+
options.project === undefined ||
|
|
164
|
+
options.sourceCollectionPath === undefined ||
|
|
165
|
+
options.index === undefined ||
|
|
166
|
+
options.host === undefined ||
|
|
167
|
+
options.apiKey === undefined ||
|
|
168
|
+
!validateBatchSize(options.batchSize)
|
|
169
|
+
) {
|
|
170
|
+
program.outputHelp()
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
projectId: options.project,
|
|
176
|
+
sourceCollectionPath: options.sourceCollectionPath,
|
|
177
|
+
queryCollectionGroup: options.queryCollectionGroup === 'true',
|
|
178
|
+
batchSize: options.batchSize,
|
|
179
|
+
meilisearch: {
|
|
180
|
+
indexUid: options.index,
|
|
181
|
+
host: options.host,
|
|
182
|
+
apiKey: options.apiKey,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const {
|
|
187
|
+
project,
|
|
188
|
+
sourceCollectionPath,
|
|
189
|
+
queryCollectionGroup,
|
|
190
|
+
index,
|
|
191
|
+
batchSize,
|
|
192
|
+
host,
|
|
193
|
+
apiKey,
|
|
194
|
+
} = await inquirer.prompt(questions)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
projectId: project,
|
|
198
|
+
sourceCollectionPath: sourceCollectionPath,
|
|
199
|
+
queryCollectionGroup: queryCollectionGroup,
|
|
200
|
+
batchSize: batchSize,
|
|
201
|
+
meilisearch: {
|
|
202
|
+
indexUid: index,
|
|
203
|
+
host: host,
|
|
204
|
+
apiKey: apiKey,
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright 2022 Meilisearch
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as admin from 'firebase-admin'
|
|
20
|
+
import { DocumentSnapshot } from 'firebase-functions/lib/providers/firestore'
|
|
21
|
+
import { CLIConfig, parseConfig } from './config'
|
|
22
|
+
import * as logs from '../logs'
|
|
23
|
+
import { adaptDocument } from '../adapter'
|
|
24
|
+
import { initMeilisearchIndex } from '../meilisearch/create-index'
|
|
25
|
+
import { Index } from '../types'
|
|
26
|
+
|
|
27
|
+
const run = async () => {
|
|
28
|
+
// Retrieve all arguments from the commande line.
|
|
29
|
+
const config: CLIConfig = await parseConfig()
|
|
30
|
+
|
|
31
|
+
// Initialize Firebase using the Google Credentials in the GOOGLE_APPLICATION_CREDENTIALS environment variable.
|
|
32
|
+
admin.initializeApp({
|
|
33
|
+
credential: admin.credential.applicationDefault(),
|
|
34
|
+
databaseURL: `https://${config.projectId}.firebaseio.com`,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const database = admin.firestore()
|
|
38
|
+
|
|
39
|
+
// Initialize Meilisearch index.
|
|
40
|
+
const index = initMeilisearchIndex(config.meilisearch)
|
|
41
|
+
|
|
42
|
+
await retrieveCollectionFromFirestore(database, config, index)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Retrieves a collection or collection group in Firestore and aggregates the data.
|
|
47
|
+
* @param {FirebaseFirestore.Firestore} database
|
|
48
|
+
* @param {CLIConfig} config
|
|
49
|
+
* @param {Index} index
|
|
50
|
+
*/
|
|
51
|
+
async function retrieveCollectionFromFirestore(
|
|
52
|
+
database: FirebaseFirestore.Firestore,
|
|
53
|
+
config: CLIConfig,
|
|
54
|
+
index: Index
|
|
55
|
+
): Promise<number> {
|
|
56
|
+
const batch: number = parseInt(config.batchSize)
|
|
57
|
+
|
|
58
|
+
let query
|
|
59
|
+
let total = 0
|
|
60
|
+
let batches = 0
|
|
61
|
+
let lastDocument = null
|
|
62
|
+
let lastBatchSize: number = batch
|
|
63
|
+
|
|
64
|
+
while (lastBatchSize === batch) {
|
|
65
|
+
batches++
|
|
66
|
+
|
|
67
|
+
if (config.queryCollectionGroup) {
|
|
68
|
+
query = database.collectionGroup(config.sourceCollectionPath).limit(batch)
|
|
69
|
+
} else {
|
|
70
|
+
query = database.collection(config.sourceCollectionPath).limit(batch)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (lastDocument !== null) {
|
|
74
|
+
query = query.startAfter(lastDocument)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapshot = await query.get()
|
|
78
|
+
const docs = snapshot.docs
|
|
79
|
+
|
|
80
|
+
if (docs.length === 0) break
|
|
81
|
+
total += await sendDocumentsToMeilisearch(docs, index)
|
|
82
|
+
|
|
83
|
+
if (docs.length) {
|
|
84
|
+
lastDocument = docs[docs.length - 1]
|
|
85
|
+
}
|
|
86
|
+
lastBatchSize = docs.length
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
logs.importData(total, batches)
|
|
90
|
+
return total
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Adapts documents and indexes them in Meilisearch.
|
|
95
|
+
* @param {any} docs
|
|
96
|
+
* @param {Index} index
|
|
97
|
+
* @param {Change<DocumentSnapshot>} change
|
|
98
|
+
*/
|
|
99
|
+
async function sendDocumentsToMeilisearch(
|
|
100
|
+
docs: DocumentSnapshot[],
|
|
101
|
+
index: Index
|
|
102
|
+
): Promise<number> {
|
|
103
|
+
const document = docs.map(snapshot => {
|
|
104
|
+
return adaptDocument(snapshot.id, snapshot)
|
|
105
|
+
})
|
|
106
|
+
try {
|
|
107
|
+
await index.addDocuments(document, { primaryKey: '_firestore_id' })
|
|
108
|
+
} catch (e) {
|
|
109
|
+
logs.error(e as Error)
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
return document.length
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
void run()
|