@byline/search-mysql 4.9.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/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export class SearchAnalyzerMismatchError extends Error {
9
+ collectionPath;
10
+ expectedFingerprint;
11
+ actualFingerprint;
12
+ code = 'SEARCH_INDEX_REINDEX_REQUIRED';
13
+ constructor(collectionPath, expectedFingerprint, actualFingerprint) {
14
+ super(`Search index for collection "${collectionPath}" uses analyzer fingerprint ` +
15
+ `"${actualFingerprint ?? '(missing)'}", but "${expectedFingerprint}" is configured. ` +
16
+ 'Clear and rebuild this collection before searching or indexing it.');
17
+ this.collectionPath = collectionPath;
18
+ this.expectedFingerprint = expectedFingerprint;
19
+ this.actualFingerprint = actualFingerprint;
20
+ this.name = 'SearchAnalyzerMismatchError';
21
+ }
22
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { SearchProvider } from '@byline/core';
9
+ import { type PortableSearchAnalyzer } from '@byline/search-analysis';
10
+ import type { Pool } from 'mysql2/promise';
11
+ export { buildIndexRow, type IndexRow, type WeightClass, weightClass } from './build-index-row.js';
12
+ export { SearchAnalyzerMismatchError } from './errors.js';
13
+ export { type MigrateOptions, type MigrateResult, migrate } from './migrate.js';
14
+ export { MySqlSearchProvider } from './mysql-search-provider.js';
15
+ export interface MySqlSearchOptions {
16
+ /** The host's existing mysql2 promise pool, normally `db.pool`. */
17
+ pool: Pool;
18
+ /** Development convenience; production should await `migrate(pool)`. */
19
+ autoMigrate?: boolean;
20
+ /** Portable analyzer fallback locale. Defaults to `en`. */
21
+ defaultLocale?: string;
22
+ /** Custom versioned portable analyzer. */
23
+ analyzer?: PortableSearchAnalyzer;
24
+ /** Optional migration progress sink. */
25
+ log?: (message: string) => void;
26
+ }
27
+ /** Construct the MySQL FULLTEXT provider while reusing the host pool. */
28
+ export declare function mysqlSearch(options: MySqlSearchOptions): SearchProvider;
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { createPortableSearchAnalyzer } from '@byline/search-analysis';
9
+ import { migrate } from './migrate.js';
10
+ import { MySqlSearchProvider } from './mysql-search-provider.js';
11
+ export { buildIndexRow, weightClass } from './build-index-row.js';
12
+ export { SearchAnalyzerMismatchError } from './errors.js';
13
+ export { migrate } from './migrate.js';
14
+ export { MySqlSearchProvider } from './mysql-search-provider.js';
15
+ /** Construct the MySQL FULLTEXT provider while reusing the host pool. */
16
+ export function mysqlSearch(options) {
17
+ const analyzer = options.analyzer ?? createPortableSearchAnalyzer({ defaultLocale: options.defaultLocale });
18
+ if (options.autoMigrate === true) {
19
+ void migrate(options.pool, { log: options.log }).catch((error) => {
20
+ const message = `[search-mysql] autoMigrate failed: ${error.message}`;
21
+ if (options.log)
22
+ options.log(message);
23
+ else
24
+ console.error(message);
25
+ });
26
+ }
27
+ return new MySqlSearchProvider(options.pool, analyzer);
28
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { Pool } from 'mysql2/promise';
9
+ export interface MigrateOptions {
10
+ log?: (message: string) => void;
11
+ }
12
+ export interface MigrateResult {
13
+ applied: number[];
14
+ }
15
+ /**
16
+ * Apply the driver-owned numbered migrations. MySQL DDL auto-commits, so an
17
+ * advisory lock plus idempotent statements serialize concurrent runners; the
18
+ * ledger row is written only after every statement in a migration succeeds.
19
+ */
20
+ export declare function migrate(pool: Pool, options?: MigrateOptions): Promise<MigrateResult>;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { MIGRATIONS } from './migrations-data.js';
9
+ const MIGRATION_LOCK = 'byline-search-mysql-migrations';
10
+ /**
11
+ * Apply the driver-owned numbered migrations. MySQL DDL auto-commits, so an
12
+ * advisory lock plus idempotent statements serialize concurrent runners; the
13
+ * ledger row is written only after every statement in a migration succeeds.
14
+ */
15
+ export async function migrate(pool, options = {}) {
16
+ const connection = await pool.getConnection();
17
+ const log = options.log ?? (() => { });
18
+ try {
19
+ const [lockRows] = await connection.query('SELECT GET_LOCK(?, 30) AS acquired', [
20
+ MIGRATION_LOCK,
21
+ ]);
22
+ if (lockRows[0]?.acquired !== 1) {
23
+ throw new Error('[search-mysql] timed out waiting for the migration lock');
24
+ }
25
+ await connection.query(`
26
+ CREATE TABLE IF NOT EXISTS byline_search_migrations (
27
+ version int NOT NULL,
28
+ applied_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
29
+ PRIMARY KEY (version)
30
+ ) ENGINE=InnoDB
31
+ `);
32
+ const [rows] = await connection.query('SELECT version FROM byline_search_migrations');
33
+ const done = new Set(rows.map((row) => Number(row.version)));
34
+ const applied = [];
35
+ for (const migration of [...MIGRATIONS].sort((left, right) => left.version - right.version)) {
36
+ if (done.has(migration.version))
37
+ continue;
38
+ try {
39
+ for (const statement of splitStatements(migration.sql)) {
40
+ await connection.query(statement);
41
+ }
42
+ await connection.query('INSERT INTO byline_search_migrations (version) VALUES (?)', [
43
+ migration.version,
44
+ ]);
45
+ applied.push(migration.version);
46
+ log(`[search-mysql] applied migration ${migration.name}`);
47
+ }
48
+ catch (error) {
49
+ throw new Error(`[search-mysql] migration ${migration.name} failed: ${error.message}`, { cause: error });
50
+ }
51
+ }
52
+ return { applied };
53
+ }
54
+ finally {
55
+ try {
56
+ await connection.query('SELECT RELEASE_LOCK(?)', [MIGRATION_LOCK]);
57
+ }
58
+ finally {
59
+ connection.release();
60
+ }
61
+ }
62
+ }
63
+ function splitStatements(sql) {
64
+ return sql
65
+ .split(';')
66
+ .map((statement) => statement.trim())
67
+ .filter((statement) => statement.length > 0);
68
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export interface EmbeddedMigration {
9
+ version: number;
10
+ name: string;
11
+ sql: string;
12
+ }
13
+ export declare const MIGRATIONS: EmbeddedMigration[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export const MIGRATIONS = [
9
+ {
10
+ version: 1,
11
+ name: '0001_init.sql',
12
+ sql: `-- @byline/search-mysql — 0001_init
13
+ --
14
+ -- Disposable portable full-text index. One row per
15
+ -- (collection_path, document_id, locale). Every searchable logical token is
16
+ -- encoded by @byline/search-analysis before MySQL's parser sees it, avoiding
17
+ -- language-specific stopword and minimum-token divergence.
18
+
19
+ CREATE TABLE IF NOT EXISTS byline_search_documents (
20
+ collection_path varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
21
+ document_id varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
22
+ locale varchar(35) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
23
+ status varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
24
+ zones json NOT NULL,
25
+ title text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
26
+ path text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
27
+ body longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
28
+ search_text longtext CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
29
+ search_a longtext CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
30
+ search_b longtext CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
31
+ search_c longtext CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
32
+ search_d longtext CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
33
+ analyzer_fingerprint varchar(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
34
+ facets json NOT NULL,
35
+ filters json NOT NULL,
36
+ updated_at datetime(6) NOT NULL,
37
+ PRIMARY KEY (collection_path, document_id, locale),
38
+ KEY byline_search_documents_collection_idx (collection_path, status),
39
+ FULLTEXT KEY byline_search_documents_text_idx (search_text),
40
+ FULLTEXT KEY byline_search_documents_a_idx (search_a),
41
+ FULLTEXT KEY byline_search_documents_b_idx (search_b),
42
+ FULLTEXT KEY byline_search_documents_c_idx (search_c),
43
+ FULLTEXT KEY byline_search_documents_d_idx (search_d)
44
+ ) ENGINE=InnoDB;
45
+
46
+ CREATE TABLE IF NOT EXISTS byline_search_index_metadata (
47
+ collection_path varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
48
+ analyzer_fingerprint varchar(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
49
+ zones json NOT NULL,
50
+ updated_at datetime(6) NOT NULL,
51
+ PRIMARY KEY (collection_path)
52
+ ) ENGINE=InnoDB;
53
+ `,
54
+ },
55
+ ];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,32 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { readdirSync, readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { MIGRATIONS } from './migrations-data.js';
13
+ const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), '../migrations');
14
+ describe('embedded migrations vs the .sql files', () => {
15
+ const sqlFiles = readdirSync(migrationsDir)
16
+ .filter((file) => file.endsWith('.sql'))
17
+ .sort();
18
+ it('embeds exactly the .sql files that ship in the package', () => {
19
+ expect(MIGRATIONS.map((migration) => migration.name).sort()).toEqual(sqlFiles);
20
+ });
21
+ it.each(sqlFiles)('embedded SQL for %s matches the file on disk', (name) => {
22
+ const onDisk = readFileSync(join(migrationsDir, name), 'utf8');
23
+ const embedded = MIGRATIONS.find((migration) => migration.name === name);
24
+ expect(embedded, `no embedded migration named ${name}`).toBeDefined();
25
+ expect(embedded?.sql.trim()).toBe(onDisk.trim());
26
+ });
27
+ it('numbers each migration from its filename prefix', () => {
28
+ for (const migration of MIGRATIONS) {
29
+ expect(migration.version).toBe(Number.parseInt(migration.name.split('_')[0] ?? '', 10));
30
+ }
31
+ });
32
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { SearchCapabilities, SearchDocument, SearchProvider, SearchQuery, SearchResults } from '@byline/core';
9
+ import { type PortableSearchAnalyzer } from '@byline/search-analysis';
10
+ import type { Pool } from 'mysql2/promise';
11
+ /** MySQL FULLTEXT implementation over portable parser-safe logical tokens. */
12
+ export declare class MySqlSearchProvider implements SearchProvider {
13
+ private readonly pool;
14
+ private readonly analyzer;
15
+ readonly capabilities: SearchCapabilities;
16
+ constructor(pool: Pool, analyzer: PortableSearchAnalyzer);
17
+ upsert(doc: SearchDocument): Promise<void>;
18
+ remove(ref: {
19
+ collectionPath: string;
20
+ documentId: string;
21
+ locale?: string;
22
+ }): Promise<void>;
23
+ reindex(opts?: {
24
+ collectionPath?: string;
25
+ }): Promise<void>;
26
+ search(query: SearchQuery): Promise<SearchResults>;
27
+ private assertAnalyzerFingerprint;
28
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { highlightPortableText } from '@byline/search-analysis';
9
+ import { buildIndexRow } from './build-index-row.js';
10
+ import { SearchAnalyzerMismatchError } from './errors.js';
11
+ import { buildPortableMySqlIndexDocument } from './portable-index-document.js';
12
+ import { buildPortableMySqlQuery } from './portable-query.js';
13
+ const CAPABILITIES = {
14
+ facets: false,
15
+ typoTolerance: false,
16
+ semantic: false,
17
+ bm25: false,
18
+ weighting: true,
19
+ highlights: true,
20
+ fullText: {
21
+ nativeAnalysis: false,
22
+ portableAnalysis: true,
23
+ allTerms: true,
24
+ anyTerms: true,
25
+ minimumShouldMatch: true,
26
+ phrase: true,
27
+ },
28
+ };
29
+ /** MySQL FULLTEXT implementation over portable parser-safe logical tokens. */
30
+ export class MySqlSearchProvider {
31
+ pool;
32
+ analyzer;
33
+ capabilities = CAPABILITIES;
34
+ constructor(pool, analyzer) {
35
+ this.pool = pool;
36
+ this.analyzer = analyzer;
37
+ }
38
+ async upsert(doc) {
39
+ const row = buildIndexRow(doc);
40
+ const indexDocument = buildPortableMySqlIndexDocument(row, this.analyzer);
41
+ const connection = await this.pool.getConnection();
42
+ try {
43
+ await connection.beginTransaction();
44
+ await connection.query(`INSERT INTO byline_search_index_metadata
45
+ (collection_path, analyzer_fingerprint, zones, updated_at)
46
+ VALUES (?, ?, ?, UTC_TIMESTAMP(6))
47
+ ON DUPLICATE KEY UPDATE collection_path = collection_path`, [row.collectionPath, indexDocument.analyzerFingerprint, JSON.stringify(row.zones)]);
48
+ const [metadata] = await connection.query(`SELECT analyzer_fingerprint, zones
49
+ FROM byline_search_index_metadata
50
+ WHERE collection_path = ?
51
+ FOR UPDATE`, [row.collectionPath]);
52
+ const actualFingerprint = metadata[0]?.analyzer_fingerprint ?? null;
53
+ if (actualFingerprint !== indexDocument.analyzerFingerprint) {
54
+ throw new SearchAnalyzerMismatchError(row.collectionPath, indexDocument.analyzerFingerprint, actualFingerprint);
55
+ }
56
+ const zones = [
57
+ ...new Set([...(parseJsonStringArray(metadata[0]?.zones) ?? []), ...row.zones]),
58
+ ];
59
+ await connection.query(`UPDATE byline_search_index_metadata
60
+ SET zones = ?, updated_at = UTC_TIMESTAMP(6)
61
+ WHERE collection_path = ?`, [JSON.stringify(zones), row.collectionPath]);
62
+ await connection.query(UPSERT_SQL, upsertParams(row, indexDocument));
63
+ await connection.commit();
64
+ }
65
+ catch (error) {
66
+ await rollback(connection);
67
+ throw error;
68
+ }
69
+ finally {
70
+ connection.release();
71
+ }
72
+ }
73
+ async remove(ref) {
74
+ if (ref.locale != null) {
75
+ await this.pool.query(`DELETE FROM byline_search_documents
76
+ WHERE collection_path = ? AND document_id = ? AND locale = ?`, [ref.collectionPath, ref.documentId, ref.locale]);
77
+ }
78
+ else {
79
+ await this.pool.query(`DELETE FROM byline_search_documents
80
+ WHERE collection_path = ? AND document_id = ?`, [ref.collectionPath, ref.documentId]);
81
+ }
82
+ }
83
+ async reindex(opts = {}) {
84
+ const connection = await this.pool.getConnection();
85
+ try {
86
+ await connection.beginTransaction();
87
+ if (opts.collectionPath != null) {
88
+ await connection.query('DELETE FROM byline_search_documents WHERE collection_path = ?', [
89
+ opts.collectionPath,
90
+ ]);
91
+ await connection.query('DELETE FROM byline_search_index_metadata WHERE collection_path = ?', [opts.collectionPath]);
92
+ }
93
+ else {
94
+ await connection.query('DELETE FROM byline_search_documents');
95
+ await connection.query('DELETE FROM byline_search_index_metadata');
96
+ }
97
+ await connection.commit();
98
+ }
99
+ catch (error) {
100
+ await rollback(connection);
101
+ throw error;
102
+ }
103
+ finally {
104
+ connection.release();
105
+ }
106
+ }
107
+ async search(query) {
108
+ const plan = this.analyzer.analyzeQuery({
109
+ query: query.query,
110
+ locale: query.locale,
111
+ matching: query.matching,
112
+ });
113
+ const translated = buildPortableMySqlQuery(plan);
114
+ if (translated.rankingQuery.length === 0)
115
+ return { hits: [], total: 0 };
116
+ await this.assertAnalyzerFingerprint(query);
117
+ const lexical = buildLexicalPredicate(translated);
118
+ const where = [lexical.sql];
119
+ const whereParams = [...lexical.params];
120
+ appendScopeFilters(query, whereParams, where, true);
121
+ const whereSql = where.join(' AND ');
122
+ const [countRows] = await this.pool.query(`SELECT COUNT(*) AS total
123
+ FROM byline_search_documents d
124
+ WHERE ${whereSql}`, whereParams);
125
+ const total = Number(countRows[0]?.total ?? 0);
126
+ const scoreSql = [
127
+ '8 * MATCH(d.search_a) AGAINST (? IN BOOLEAN MODE)',
128
+ '4 * MATCH(d.search_b) AGAINST (? IN BOOLEAN MODE)',
129
+ '2 * MATCH(d.search_c) AGAINST (? IN BOOLEAN MODE)',
130
+ '1 * MATCH(d.search_d) AGAINST (? IN BOOLEAN MODE)',
131
+ ].join(' + ');
132
+ const limit = query.limit ?? 20;
133
+ const offset = query.offset ?? 0;
134
+ const [hitRows] = await this.pool.query(`SELECT d.collection_path, d.document_id, d.locale, d.title, d.path, d.body,
135
+ (${scoreSql}) AS score
136
+ FROM byline_search_documents d
137
+ WHERE ${whereSql}
138
+ ORDER BY score DESC, d.updated_at DESC,
139
+ d.collection_path, d.document_id, d.locale
140
+ LIMIT ? OFFSET ?`, [
141
+ translated.rankingQuery,
142
+ translated.rankingQuery,
143
+ translated.rankingQuery,
144
+ translated.rankingQuery,
145
+ ...whereParams,
146
+ limit,
147
+ offset,
148
+ ]);
149
+ const hits = hitRows.map((row) => {
150
+ const highlight = highlightPortableText({
151
+ text: row.body,
152
+ plan,
153
+ analyzer: this.analyzer,
154
+ });
155
+ return {
156
+ collectionPath: row.collection_path,
157
+ documentId: row.document_id,
158
+ locale: row.locale,
159
+ title: row.title,
160
+ path: row.path,
161
+ score: Number(row.score),
162
+ ...(highlight == null ? {} : { highlights: { body: [highlight] } }),
163
+ };
164
+ });
165
+ return { hits, total };
166
+ }
167
+ async assertAnalyzerFingerprint(query) {
168
+ const params = [this.analyzer.fingerprint];
169
+ const where = ['m.analyzer_fingerprint <> ?'];
170
+ appendMetadataScopeFilters(query, params, where);
171
+ const [rows] = await this.pool.query(`SELECT m.collection_path, m.analyzer_fingerprint
172
+ FROM byline_search_index_metadata m
173
+ WHERE ${where.join(' AND ')}
174
+ LIMIT 1`, params);
175
+ const row = rows[0];
176
+ if (row != null) {
177
+ throw new SearchAnalyzerMismatchError(row.collection_path, this.analyzer.fingerprint, row.analyzer_fingerprint);
178
+ }
179
+ }
180
+ }
181
+ function appendMetadataScopeFilters(query, params, where) {
182
+ if (query.collectionPath != null) {
183
+ params.push(query.collectionPath);
184
+ where.push('m.collection_path = ?');
185
+ }
186
+ if (query.zone != null) {
187
+ params.push(query.zone);
188
+ where.push('JSON_CONTAINS(m.zones, JSON_ARRAY(?))');
189
+ }
190
+ }
191
+ function parseJsonStringArray(value) {
192
+ if (value == null)
193
+ return undefined;
194
+ if (Array.isArray(value))
195
+ return value;
196
+ const parsed = JSON.parse(value);
197
+ return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')
198
+ ? parsed
199
+ : undefined;
200
+ }
201
+ function upsertParams(row, indexDocument) {
202
+ const updatedAt = new Date(row.updatedAt);
203
+ if (Number.isNaN(updatedAt.valueOf())) {
204
+ throw new TypeError(`Search document updatedAt is invalid: ${row.updatedAt}`);
205
+ }
206
+ return [
207
+ row.collectionPath,
208
+ row.documentId,
209
+ row.locale,
210
+ row.status,
211
+ JSON.stringify(row.zones),
212
+ row.title,
213
+ row.path,
214
+ row.body,
215
+ indexDocument.searchText,
216
+ indexDocument.weighted.A,
217
+ indexDocument.weighted.B,
218
+ indexDocument.weighted.C,
219
+ indexDocument.weighted.D,
220
+ indexDocument.analyzerFingerprint,
221
+ JSON.stringify(row.facets),
222
+ JSON.stringify(row.filters),
223
+ updatedAt,
224
+ ];
225
+ }
226
+ function buildLexicalPredicate(query) {
227
+ const params = [];
228
+ const match = (value) => {
229
+ params.push(value);
230
+ return 'MATCH(d.search_text) AGAINST (? IN BOOLEAN MODE) > 0';
231
+ };
232
+ const concepts = query.conceptQueries.map(match);
233
+ let conceptSql;
234
+ if (query.minimumShouldMatch != null) {
235
+ params.push(query.minimumShouldMatch);
236
+ conceptSql = `(${concepts.map((sql) => `IF(${sql}, 1, 0)`).join(' + ')}) >= ?`;
237
+ }
238
+ else {
239
+ const operator = query.operator === 'all' ? ' AND ' : ' OR ';
240
+ conceptSql = `(${concepts.join(operator)})`;
241
+ }
242
+ if (query.gramQueries.length > 0) {
243
+ conceptSql = `(${conceptSql} OR ${query.gramQueries.map(match).join(' OR ')})`;
244
+ }
245
+ const clauses = [conceptSql];
246
+ for (const phraseAlternatives of query.phraseQueries) {
247
+ if (phraseAlternatives.length === 0) {
248
+ clauses.push('(FALSE)');
249
+ continue;
250
+ }
251
+ clauses.push(`(${phraseAlternatives.map(match).join(' OR ')})`);
252
+ }
253
+ return { sql: clauses.join(' AND '), params };
254
+ }
255
+ function appendScopeFilters(query, params, where, includeStatus) {
256
+ if (query.collectionPath != null) {
257
+ params.push(query.collectionPath);
258
+ where.push('d.collection_path = ?');
259
+ }
260
+ if (query.zone != null) {
261
+ params.push(query.zone);
262
+ where.push("JSON_CONTAINS(d.zones, JSON_QUOTE(?), '$') = 1");
263
+ }
264
+ if (query.locale != null) {
265
+ params.push(query.locale);
266
+ where.push('d.locale = ?');
267
+ }
268
+ if (includeStatus && query.status !== 'any') {
269
+ params.push('published');
270
+ where.push('d.status = ?');
271
+ }
272
+ }
273
+ async function rollback(connection) {
274
+ try {
275
+ await connection.rollback();
276
+ }
277
+ catch {
278
+ // Preserve the original operation error.
279
+ }
280
+ }
281
+ const UPSERT_SQL = `INSERT INTO byline_search_documents
282
+ (collection_path, document_id, locale, status, zones, title, path, body,
283
+ search_text, search_a, search_b, search_c, search_d, analyzer_fingerprint,
284
+ facets, filters, updated_at)
285
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
286
+ ON DUPLICATE KEY UPDATE
287
+ status = VALUES(status),
288
+ zones = VALUES(zones),
289
+ title = VALUES(title),
290
+ path = VALUES(path),
291
+ body = VALUES(body),
292
+ search_text = VALUES(search_text),
293
+ search_a = VALUES(search_a),
294
+ search_b = VALUES(search_b),
295
+ search_c = VALUES(search_c),
296
+ search_d = VALUES(search_d),
297
+ analyzer_fingerprint = VALUES(analyzer_fingerprint),
298
+ facets = VALUES(facets),
299
+ filters = VALUES(filters),
300
+ updated_at = VALUES(updated_at)`;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type PortableSearchAnalyzer } from '@byline/search-analysis';
9
+ import type { IndexRow, WeightClass } from './build-index-row.js';
10
+ export interface PortableMySqlIndexDocument {
11
+ searchText: string;
12
+ weighted: Record<WeightClass, string>;
13
+ analyzerFingerprint: string;
14
+ }
15
+ /**
16
+ * Encode portable logical terms into text streams consumed by MySQL FULLTEXT.
17
+ * Separate exact, expansion-kind, and Han-gram streams preserve adjacency for
18
+ * phrase matching without letting phrases cross weight classes or token
19
+ * streams.
20
+ */
21
+ export declare function buildPortableMySqlIndexDocument(row: Pick<IndexRow, 'locale' | 'weighted'>, analyzer: PortableSearchAnalyzer): PortableMySqlIndexDocument;