@cogenta/schema 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/dist/define-collection.d.ts +8 -0
- package/dist/define-collection.d.ts.map +1 -0
- package/dist/define-collection.js +311 -0
- package/dist/define-collection.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/fields.d.ts +117 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +107 -0
- package/dist/fields.js.map +1 -0
- package/dist/generate-schema-json.d.ts +61 -0
- package/dist/generate-schema-json.d.ts.map +1 -0
- package/dist/generate-schema-json.js +128 -0
- package/dist/generate-schema-json.js.map +1 -0
- package/dist/generate-types.d.ts +5 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +220 -0
- package/dist/generate-types.js.map +1 -0
- package/dist/id.d.ts +8 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +8 -0
- package/dist/id.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/rich-text.d.ts +164 -0
- package/dist/rich-text.d.ts.map +1 -0
- package/dist/rich-text.js +97 -0
- package/dist/rich-text.js.map +1 -0
- package/dist/routing/ids.d.ts +18 -0
- package/dist/routing/ids.d.ts.map +1 -0
- package/dist/routing/ids.js +41 -0
- package/dist/routing/ids.js.map +1 -0
- package/dist/routing/index.d.ts +13 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +7 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/routing/redirects.d.ts +76 -0
- package/dist/routing/redirects.d.ts.map +1 -0
- package/dist/routing/redirects.js +237 -0
- package/dist/routing/redirects.js.map +1 -0
- package/dist/routing/resolve.d.ts +38 -0
- package/dist/routing/resolve.d.ts.map +1 -0
- package/dist/routing/resolve.js +23 -0
- package/dist/routing/resolve.js.map +1 -0
- package/dist/routing/router.d.ts +51 -0
- package/dist/routing/router.d.ts.map +1 -0
- package/dist/routing/router.js +132 -0
- package/dist/routing/router.js.map +1 -0
- package/dist/routing/slug-change.d.ts +39 -0
- package/dist/routing/slug-change.d.ts.map +1 -0
- package/dist/routing/slug-change.js +44 -0
- package/dist/routing/slug-change.js.map +1 -0
- package/dist/routing/slug.d.ts +82 -0
- package/dist/routing/slug.d.ts.map +1 -0
- package/dist/routing/slug.js +124 -0
- package/dist/routing/slug.js.map +1 -0
- package/dist/routing/slugify.d.ts +29 -0
- package/dist/routing/slugify.d.ts.map +1 -0
- package/dist/routing/slugify.js +104 -0
- package/dist/routing/slugify.js.map +1 -0
- package/dist/scheduling/index.d.ts +3 -0
- package/dist/scheduling/index.d.ts.map +1 -0
- package/dist/scheduling/index.js +2 -0
- package/dist/scheduling/index.js.map +1 -0
- package/dist/scheduling/publish.d.ts +71 -0
- package/dist/scheduling/publish.d.ts.map +1 -0
- package/dist/scheduling/publish.js +122 -0
- package/dist/scheduling/publish.js.map +1 -0
- package/dist/search/extract.d.ts +25 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +188 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +36 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +44 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/mysql.d.ts +7 -0
- package/dist/search/mysql.d.ts.map +1 -0
- package/dist/search/mysql.js +121 -0
- package/dist/search/mysql.js.map +1 -0
- package/dist/search/postgres.d.ts +9 -0
- package/dist/search/postgres.d.ts.map +1 -0
- package/dist/search/postgres.js +167 -0
- package/dist/search/postgres.js.map +1 -0
- package/dist/search/query.d.ts +35 -0
- package/dist/search/query.d.ts.map +1 -0
- package/dist/search/query.js +116 -0
- package/dist/search/query.js.map +1 -0
- package/dist/search/sqlite.d.ts +16 -0
- package/dist/search/sqlite.d.ts.map +1 -0
- package/dist/search/sqlite.js +166 -0
- package/dist/search/sqlite.js.map +1 -0
- package/dist/search/table.d.ts +49 -0
- package/dist/search/table.d.ts.map +1 -0
- package/dist/search/table.js +86 -0
- package/dist/search/table.js.map +1 -0
- package/dist/search/text.d.ts +42 -0
- package/dist/search/text.d.ts.map +1 -0
- package/dist/search/text.js +56 -0
- package/dist/search/text.js.map +1 -0
- package/dist/search/types.d.ts +98 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/search/types.js +2 -0
- package/dist/search/types.js.map +1 -0
- package/dist/store/columns.d.ts +33 -0
- package/dist/store/columns.d.ts.map +1 -0
- package/dist/store/columns.js +113 -0
- package/dist/store/columns.js.map +1 -0
- package/dist/store/cursor.d.ts +23 -0
- package/dist/store/cursor.d.ts.map +1 -0
- package/dist/store/cursor.js +36 -0
- package/dist/store/cursor.js.map +1 -0
- package/dist/store/diff.d.ts +59 -0
- package/dist/store/diff.d.ts.map +1 -0
- package/dist/store/diff.js +124 -0
- package/dist/store/diff.js.map +1 -0
- package/dist/store/fragments.d.ts +13 -0
- package/dist/store/fragments.d.ts.map +1 -0
- package/dist/store/fragments.js +25 -0
- package/dist/store/fragments.js.map +1 -0
- package/dist/store/index.d.ts +22 -0
- package/dist/store/index.d.ts.map +1 -0
- package/dist/store/index.js +16 -0
- package/dist/store/index.js.map +1 -0
- package/dist/store/naming.d.ts +25 -0
- package/dist/store/naming.d.ts.map +1 -0
- package/dist/store/naming.js +80 -0
- package/dist/store/naming.js.map +1 -0
- package/dist/store/read-only.d.ts +16 -0
- package/dist/store/read-only.d.ts.map +1 -0
- package/dist/store/read-only.js +36 -0
- package/dist/store/read-only.js.map +1 -0
- package/dist/store/store.d.ts +52 -0
- package/dist/store/store.d.ts.map +1 -0
- package/dist/store/store.js +706 -0
- package/dist/store/store.js.map +1 -0
- package/dist/store/tables.d.ts +40 -0
- package/dist/store/tables.d.ts.map +1 -0
- package/dist/store/tables.js +267 -0
- package/dist/store/tables.js.map +1 -0
- package/dist/store/types.d.ts +105 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +2 -0
- package/dist/store/types.js.map +1 -0
- package/dist/store/uuid.d.ts +6 -0
- package/dist/store/uuid.d.ts.map +1 -0
- package/dist/store/uuid.js +64 -0
- package/dist/store/uuid.js.map +1 -0
- package/dist/store/values.d.ts +40 -0
- package/dist/store/values.d.ts.map +1 -0
- package/dist/store/values.js +160 -0
- package/dist/store/values.js.map +1 -0
- package/dist/system-fields.d.ts +60 -0
- package/dist/system-fields.d.ts.map +1 -0
- package/dist/system-fields.js +73 -0
- package/dist/system-fields.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +32 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +54 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +183 -0
- package/dist/validation.js.map +1 -0
- package/package.json +43 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContentStatus } from '../types.js';
|
|
2
|
+
import type { SearchDocument, SearchQuery, SearchReference, SearchResults } from './types.js';
|
|
3
|
+
export interface NormalisedQuery {
|
|
4
|
+
readonly tokens: readonly string[];
|
|
5
|
+
readonly locale: string;
|
|
6
|
+
readonly status: ContentStatus;
|
|
7
|
+
readonly collections: readonly string[];
|
|
8
|
+
readonly size: number;
|
|
9
|
+
readonly offset: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Validates a query and reduces it to what every engine needs.
|
|
13
|
+
*
|
|
14
|
+
* The locale is required rather than defaulted: a default would be a guess
|
|
15
|
+
* about which language a reader is entitled to, and guessing there is how a
|
|
16
|
+
* site leaks a half-translated page.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normaliseQuery(query: SearchQuery): NormalisedQuery;
|
|
19
|
+
export declare function assertReference(reference: SearchReference): void;
|
|
20
|
+
/** The document as it is stored: title trimmed, body folded by the caller. */
|
|
21
|
+
export declare function assertDocument(document: SearchDocument): void;
|
|
22
|
+
export declare function storedTitle(document: SearchDocument): string;
|
|
23
|
+
/**
|
|
24
|
+
* Turns the fetched rows into a page.
|
|
25
|
+
*
|
|
26
|
+
* One row more than the page size is always fetched, and its presence is the
|
|
27
|
+
* answer to "is there a next page" — the same trick the content store uses, and
|
|
28
|
+
* for the same reason: a second `count(*)` over a full-text match is as
|
|
29
|
+
* expensive as the search itself.
|
|
30
|
+
*/
|
|
31
|
+
export declare function toResults(rows: readonly Record<string, unknown>[], scoreOf: (row: Record<string, unknown>) => number, normalised: NormalisedQuery): SearchResults;
|
|
32
|
+
/** The answer to a query no engine can match: no terms left after tokenising. */
|
|
33
|
+
export declare const EMPTY_RESULTS: SearchResults;
|
|
34
|
+
export declare function numeric(value: unknown): number;
|
|
35
|
+
//# sourceMappingURL=query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/search/query.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAEhD,OAAO,KAAK,EACV,cAAc,EAEd,WAAW,EACX,eAAe,EACf,aAAa,EACd,MAAM,YAAY,CAAA;AAiBnB,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;IAC9B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IACvC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAuBD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CA6BlE;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,eAAe,GAAG,IAAI,CAShE;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAW7D;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,cAAc,GAAG,MAAM,CAE5D;AAUD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACxC,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,MAAM,EACjD,UAAU,EAAE,eAAe,GAC1B,aAAa,CAkBf;AAED,iFAAiF;AACjF,eAAO,MAAM,aAAa,EAAE,aAA8D,CAAA;AAE1F,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAG9C"}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { CogentaError } from '@cogenta/core';
|
|
2
|
+
import { condense, queryTokens } from './text.js';
|
|
3
|
+
/** A page big enough for a result list, small enough that a bad caller cannot
|
|
4
|
+
* ask the database to rank the whole site. */
|
|
5
|
+
const DEFAULT_LIMIT = 20;
|
|
6
|
+
const MAX_LIMIT = 100;
|
|
7
|
+
/**
|
|
8
|
+
* Past this the ranking is meaningless anyway, and a deep offset makes every
|
|
9
|
+
* engine sort the whole matching set to throw it away. A search that needs page
|
|
10
|
+
* five hundred is a report, not a search.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_OFFSET = 1000;
|
|
13
|
+
/** Titles are a label, not a document; a runaway one would bloat every row. */
|
|
14
|
+
const MAX_TITLE_LENGTH = 500;
|
|
15
|
+
function invalid(message, hint, details) {
|
|
16
|
+
return new CogentaError({ code: 'QUERY_INVALID', message, hint, details });
|
|
17
|
+
}
|
|
18
|
+
function boundedInteger(value, fallback, max, name) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return fallback;
|
|
21
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
22
|
+
throw invalid(`The search ${name} must be a whole number of zero or more, received ${String(value)}.`, 'Pass a plain integer, or leave it out to take the default.', { [name]: value });
|
|
23
|
+
}
|
|
24
|
+
return Math.min(value, max);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Validates a query and reduces it to what every engine needs.
|
|
28
|
+
*
|
|
29
|
+
* The locale is required rather than defaulted: a default would be a guess
|
|
30
|
+
* about which language a reader is entitled to, and guessing there is how a
|
|
31
|
+
* site leaks a half-translated page.
|
|
32
|
+
*/
|
|
33
|
+
export function normaliseQuery(query) {
|
|
34
|
+
if (typeof query.locale !== 'string' || query.locale.trim().length === 0) {
|
|
35
|
+
throw invalid('A search needs a locale.', "Pass the locale the reader is browsing, such as { locale: 'fr' }. Search never spans languages.", { locale: query.locale });
|
|
36
|
+
}
|
|
37
|
+
for (const collection of query.collections ?? []) {
|
|
38
|
+
if (typeof collection !== 'string' || collection.length === 0) {
|
|
39
|
+
throw invalid('A collection name in a search filter is empty.', 'Pass the names of collections to restrict to, or leave `collections` out to search all of them.', { collections: query.collections });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
tokens: queryTokens(query.text ?? ''),
|
|
44
|
+
locale: query.locale,
|
|
45
|
+
// The default is the only safe one: a caller that forgets to say gets what
|
|
46
|
+
// the public may see, never a draft.
|
|
47
|
+
status: query.status ?? 'published',
|
|
48
|
+
collections: query.collections ?? [],
|
|
49
|
+
size: Math.max(1, boundedInteger(query.limit, DEFAULT_LIMIT, MAX_LIMIT, 'limit')),
|
|
50
|
+
offset: boundedInteger(query.offset, 0, MAX_OFFSET, 'offset'),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function assertReference(reference) {
|
|
54
|
+
if (reference.id.length === 0 || reference.collection.length === 0) {
|
|
55
|
+
throw new CogentaError({
|
|
56
|
+
code: 'CONTENT_INVALID',
|
|
57
|
+
message: 'A search index reference needs both an entry id and a collection.',
|
|
58
|
+
hint: 'Pass { id, collection } from the entry being indexed.',
|
|
59
|
+
details: { reference },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The document as it is stored: title trimmed, body folded by the caller. */
|
|
64
|
+
export function assertDocument(document) {
|
|
65
|
+
assertReference(document);
|
|
66
|
+
if (document.locale.length === 0) {
|
|
67
|
+
throw new CogentaError({
|
|
68
|
+
code: 'CONTENT_INVALID',
|
|
69
|
+
message: `The entry "${document.id}" has no locale, so it cannot be indexed.`,
|
|
70
|
+
hint: 'Every entry carries a locale (ADR-0014); index the entry as it was read, not a partial copy.',
|
|
71
|
+
details: { id: document.id, collection: document.collection },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function storedTitle(document) {
|
|
76
|
+
return condense(document.title).slice(0, MAX_TITLE_LENGTH);
|
|
77
|
+
}
|
|
78
|
+
function text(value) {
|
|
79
|
+
return typeof value === 'string'
|
|
80
|
+
? value
|
|
81
|
+
: value === null || value === undefined
|
|
82
|
+
? ''
|
|
83
|
+
: String(value);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Turns the fetched rows into a page.
|
|
87
|
+
*
|
|
88
|
+
* One row more than the page size is always fetched, and its presence is the
|
|
89
|
+
* answer to "is there a next page" — the same trick the content store uses, and
|
|
90
|
+
* for the same reason: a second `count(*)` over a full-text match is as
|
|
91
|
+
* expensive as the search itself.
|
|
92
|
+
*/
|
|
93
|
+
export function toResults(rows, scoreOf, normalised) {
|
|
94
|
+
const hasMore = rows.length > normalised.size;
|
|
95
|
+
const page = rows.slice(0, normalised.size);
|
|
96
|
+
const hits = page.map((row) => ({
|
|
97
|
+
id: text(row['entry_id']),
|
|
98
|
+
collection: text(row['collection']),
|
|
99
|
+
locale: text(row['locale']),
|
|
100
|
+
status: text(row['status']),
|
|
101
|
+
title: text(row['title']),
|
|
102
|
+
score: scoreOf(row),
|
|
103
|
+
}));
|
|
104
|
+
return {
|
|
105
|
+
hits,
|
|
106
|
+
hasMore,
|
|
107
|
+
nextOffset: hasMore ? normalised.offset + normalised.size : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The answer to a query no engine can match: no terms left after tokenising. */
|
|
111
|
+
export const EMPTY_RESULTS = { hits: [], hasMore: false, nextOffset: null };
|
|
112
|
+
export function numeric(value) {
|
|
113
|
+
const parsed = Number(value);
|
|
114
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.js","sourceRoot":"","sources":["../../src/search/query.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type DatabaseHandle } from '@cogenta/core';
|
|
2
|
+
import type { SearchDriver } from './types.js';
|
|
3
|
+
export interface SqliteSearchOptions {
|
|
4
|
+
readonly db: DatabaseHandle;
|
|
5
|
+
/**
|
|
6
|
+
* Whether to use FTS5 when it is available.
|
|
7
|
+
*
|
|
8
|
+
* Defaults to true. Setting it to false forces the substring fallback, which
|
|
9
|
+
* an operator may want on a build whose FTS5 is present but broken — and which
|
|
10
|
+
* is how the contract suite proves the degraded path still honours the same
|
|
11
|
+
* guarantees as the optimal one.
|
|
12
|
+
*/
|
|
13
|
+
readonly fts5?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function createSqliteSearch(options: SqliteSearchOptions): Promise<SearchDriver>;
|
|
16
|
+
//# sourceMappingURL=sqlite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/search/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,cAAc,EAMpB,MAAM,eAAe,CAAA;AAsBtB,OAAO,KAAK,EAAkB,YAAY,EAAgC,MAAM,YAAY,CAAA;AA8B5F,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,cAAc,CAAA;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,YAAY,CAAC,CAmL5F"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { identifier, sql, limit as sqlLimit, } from '@cogenta/core';
|
|
2
|
+
import { joinFragments } from '../store/fragments.js';
|
|
3
|
+
import { assertDocument, assertReference, EMPTY_RESULTS, normaliseQuery, numeric, storedTitle, toResults, } from './query.js';
|
|
4
|
+
import { allOf, baseColumns, primaryKey, SEARCH_COLUMNS, SEARCH_FTS_TABLE, SEARCH_TABLE, scopeFilters, selectedColumns, } from './table.js';
|
|
5
|
+
import { foldText } from './text.js';
|
|
6
|
+
/**
|
|
7
|
+
* SQLite full-text search: FTS5 when the runtime has it, `LIKE` when it does not.
|
|
8
|
+
*
|
|
9
|
+
* FTS5 is a compile-time option. `node:sqlite` ships with it enabled in the
|
|
10
|
+
* official Node builds, but Cogenta also has to run on a distribution build, on
|
|
11
|
+
* a shared host, and on whatever Node an ARM box happens to carry — and rule R1
|
|
12
|
+
* says an infrastructure need must have an implementation that requires nothing.
|
|
13
|
+
* So availability is *probed*, once, by trying to create the virtual table, and
|
|
14
|
+
* a failure degrades to a substring scan instead of taking the site down.
|
|
15
|
+
*
|
|
16
|
+
* The degraded path is genuinely worse and says so in `health()`: no ranking, no
|
|
17
|
+
* stemming, a full scan of the index table. It is not a silent fallback — an
|
|
18
|
+
* administrator reading `cogenta doctor` sees which of the two is running.
|
|
19
|
+
*/
|
|
20
|
+
const SCOPE_INDEX = 'cogenta_search_scope';
|
|
21
|
+
/**
|
|
22
|
+
* `"term"*` for every term, all required.
|
|
23
|
+
*
|
|
24
|
+
* The quotes turn each token into an FTS5 string literal, so even a token that
|
|
25
|
+
* collided with a keyword (`AND`, `NEAR`, `NOT`) is read as a word. Tokens are
|
|
26
|
+
* letters and digits only, so the closing quote can never be escaped out of.
|
|
27
|
+
*/
|
|
28
|
+
function matchExpression(tokens) {
|
|
29
|
+
return tokens.map((token) => `"${token}"*`).join(' AND ');
|
|
30
|
+
}
|
|
31
|
+
export async function createSqliteSearch(options) {
|
|
32
|
+
const { db } = options;
|
|
33
|
+
const dialect = 'sqlite';
|
|
34
|
+
const wanted = options.fts5 ?? true;
|
|
35
|
+
const fts = identifier(SEARCH_FTS_TABLE, dialect);
|
|
36
|
+
const table = identifier(SEARCH_TABLE, dialect);
|
|
37
|
+
const content = identifier(SEARCH_COLUMNS.content, dialect);
|
|
38
|
+
const entryId = identifier(SEARCH_COLUMNS.entryId, dialect);
|
|
39
|
+
const collection = identifier(SEARCH_COLUMNS.collection, dialect);
|
|
40
|
+
const available = wanted && (await createFtsTable());
|
|
41
|
+
const target = available ? fts : table;
|
|
42
|
+
if (!available) {
|
|
43
|
+
await db.query(sql `create table if not exists ${table} (${joinFragments([...baseColumns(dialect), primaryKey(dialect)], ', ')})`);
|
|
44
|
+
await db.query(sql `create index if not exists ${identifier(SCOPE_INDEX, dialect)}
|
|
45
|
+
on ${table} (${identifier(SEARCH_COLUMNS.locale, dialect)},
|
|
46
|
+
${identifier(SEARCH_COLUMNS.status, dialect)})`);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
dialect,
|
|
50
|
+
index: async (entry) => {
|
|
51
|
+
assertDocument(entry);
|
|
52
|
+
const values = sql `(${entry.id}, ${entry.collection}, ${entry.locale}, ${entry.status},
|
|
53
|
+
${storedTitle(entry)}, ${foldText(`${entry.title} ${entry.body}`)})`;
|
|
54
|
+
const columns = joinFragments([
|
|
55
|
+
entryId,
|
|
56
|
+
collection,
|
|
57
|
+
identifier(SEARCH_COLUMNS.locale, dialect),
|
|
58
|
+
identifier(SEARCH_COLUMNS.status, dialect),
|
|
59
|
+
identifier(SEARCH_COLUMNS.title, dialect),
|
|
60
|
+
content,
|
|
61
|
+
], ', ');
|
|
62
|
+
if (!available) {
|
|
63
|
+
// A primary key exists on the plain table, so one statement does it.
|
|
64
|
+
await db.query(sql `insert or replace into ${table} (${columns}) values ${values}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// An FTS5 table has no unique constraint to conflict on, so re-indexing is
|
|
68
|
+
// a delete and an insert. Both inside one immediate transaction: a reader
|
|
69
|
+
// that lands between them would otherwise find the entry missing from an
|
|
70
|
+
// index that never stopped being complete.
|
|
71
|
+
await db.transaction(async (tx) => {
|
|
72
|
+
await tx.query(sql `delete from ${fts} where ${entryId} = ${entry.id} and ${collection} = ${entry.collection}`);
|
|
73
|
+
await tx.query(sql `insert into ${fts} (${columns}) values ${values}`);
|
|
74
|
+
}, { immediate: true });
|
|
75
|
+
},
|
|
76
|
+
remove: async (reference) => {
|
|
77
|
+
assertReference(reference);
|
|
78
|
+
await db.query(sql `delete from ${target}
|
|
79
|
+
where ${collection} = ${reference.collection} and ${entryId} = ${reference.id}`);
|
|
80
|
+
},
|
|
81
|
+
search: async (query) => {
|
|
82
|
+
const normalised = normaliseQuery(query);
|
|
83
|
+
if (normalised.tokens.length === 0)
|
|
84
|
+
return EMPTY_RESULTS;
|
|
85
|
+
const filters = scopeFilters(dialect, normalised);
|
|
86
|
+
if (!available) {
|
|
87
|
+
// No ranking to offer, so none is invented: every hit scores zero and
|
|
88
|
+
// the order is the stable one. A caller that sorted on `score` would
|
|
89
|
+
// otherwise believe a substring scan had an opinion about relevance.
|
|
90
|
+
const like = normalised.tokens.map((token) => sql `${content} like ${`%${token}%`}`);
|
|
91
|
+
const scanned = await db.query(sql `select ${selectedColumns(dialect)}
|
|
92
|
+
from ${table}
|
|
93
|
+
where ${allOf([...like, ...filters])}
|
|
94
|
+
order by ${entryId} asc
|
|
95
|
+
limit ${sqlLimit(normalised.size + 1)} offset ${sqlLimit(normalised.offset)}`);
|
|
96
|
+
return toResults(scanned.rows, () => 0, normalised);
|
|
97
|
+
}
|
|
98
|
+
// bm25() returns a negative number, best first. Negating it makes `score`
|
|
99
|
+
// mean the same thing here as on the other two dialects: higher is better.
|
|
100
|
+
const score = sql `-bm25(${fts})`;
|
|
101
|
+
const found = await db.query(sql `select ${selectedColumns(dialect)}, ${score} as ${identifier('score', dialect)}
|
|
102
|
+
from ${fts}
|
|
103
|
+
where ${allOf([sql `${fts} match ${matchExpression(normalised.tokens)}`, ...filters])}
|
|
104
|
+
order by ${identifier('score', dialect)} desc, ${entryId} asc
|
|
105
|
+
limit ${sqlLimit(normalised.size + 1)} offset ${sqlLimit(normalised.offset)}`);
|
|
106
|
+
return toResults(found.rows, (row) => numeric(row['score']), normalised);
|
|
107
|
+
},
|
|
108
|
+
clear: async (scope) => {
|
|
109
|
+
const where = scope?.collection === undefined ? sql `` : sql ` where ${collection} = ${scope.collection}`;
|
|
110
|
+
await db.query(sql `delete from ${target}${where}`);
|
|
111
|
+
},
|
|
112
|
+
health: async () => {
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
const counted = await db.query(sql `select count(*) as ${identifier('total', dialect)} from ${target}`);
|
|
115
|
+
const documents = numeric(counted.rows[0]?.total);
|
|
116
|
+
if (available) {
|
|
117
|
+
return {
|
|
118
|
+
status: 'ok',
|
|
119
|
+
driver: 'sqlite',
|
|
120
|
+
tier: 'degraded',
|
|
121
|
+
latencyMs: Date.now() - started,
|
|
122
|
+
message: 'Full-text search on an FTS5 table, ranked with BM25.',
|
|
123
|
+
details: { documents, engine: 'fts5' },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
status: 'degraded',
|
|
128
|
+
driver: 'sqlite',
|
|
129
|
+
tier: 'degraded',
|
|
130
|
+
latencyMs: Date.now() - started,
|
|
131
|
+
message: wanted
|
|
132
|
+
? 'This SQLite build has no FTS5, so search falls back to a substring scan: no ranking, no stemming, and it slows down as the site grows. Rebuild Node with FTS5, or move to Postgres or MySQL.'
|
|
133
|
+
: 'FTS5 is turned off, so search falls back to a substring scan: no ranking and no stemming.',
|
|
134
|
+
details: { documents, engine: 'like' },
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Probes FTS5 by trying to use it.
|
|
140
|
+
*
|
|
141
|
+
* Reading `pragma compile_options` would be the obvious test and is the wrong
|
|
142
|
+
* one: it reports how the library was built, not whether this connection can
|
|
143
|
+
* create the module — a distribution can ship FTS5 disabled at runtime. The
|
|
144
|
+
* statement that would fail later is the honest probe.
|
|
145
|
+
*/
|
|
146
|
+
async function createFtsTable() {
|
|
147
|
+
const columns = [
|
|
148
|
+
// Only `content` is indexed. The rest are stored so a hit needs no join,
|
|
149
|
+
// but tokenising an id or a status would pollute every ranking.
|
|
150
|
+
sql `${entryId} unindexed`,
|
|
151
|
+
sql `${collection} unindexed`,
|
|
152
|
+
sql `${identifier(SEARCH_COLUMNS.locale, dialect)} unindexed`,
|
|
153
|
+
sql `${identifier(SEARCH_COLUMNS.status, dialect)} unindexed`,
|
|
154
|
+
sql `${identifier(SEARCH_COLUMNS.title, dialect)} unindexed`,
|
|
155
|
+
content,
|
|
156
|
+
];
|
|
157
|
+
try {
|
|
158
|
+
await db.query(sql `create virtual table if not exists ${fts} using fts5(${joinFragments(columns, ', ')})`);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=sqlite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/search/sqlite.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type DatabaseDialect, type SqlFragment } from '@cogenta/core';
|
|
2
|
+
/**
|
|
3
|
+
* The physical index, shared by the three engines.
|
|
4
|
+
*
|
|
5
|
+
* One table for every collection rather than one per collection: a search box
|
|
6
|
+
* is site-wide, and a union across twenty collection tables would have to be
|
|
7
|
+
* rebuilt every time a schema changes. The engine-specific machinery — a
|
|
8
|
+
* `tsvector` column, a `FULLTEXT` key, an FTS5 twin — hangs off this same set
|
|
9
|
+
* of columns, so the filters and the row mapping are written once.
|
|
10
|
+
*
|
|
11
|
+
* The name is fixed rather than derived from a collection, so it is a constant
|
|
12
|
+
* and not a `naming.ts` function.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SEARCH_TABLE = "cogenta_search";
|
|
15
|
+
/** SQLite only: the FTS5 virtual table. See `sqlite.ts`. */
|
|
16
|
+
export declare const SEARCH_FTS_TABLE = "cogenta_search_fts";
|
|
17
|
+
export declare const SEARCH_COLUMNS: {
|
|
18
|
+
readonly entryId: 'entry_id';
|
|
19
|
+
readonly collection: 'collection';
|
|
20
|
+
readonly locale: 'locale';
|
|
21
|
+
readonly status: 'status';
|
|
22
|
+
readonly title: 'title';
|
|
23
|
+
/** The folded, extracted text. Never the stored JSON. */
|
|
24
|
+
readonly content: 'content';
|
|
25
|
+
};
|
|
26
|
+
export declare function baseColumns(dialect: DatabaseDialect): SqlFragment[];
|
|
27
|
+
/**
|
|
28
|
+
* The collection leads the key so that clearing or re-indexing one collection
|
|
29
|
+
* touches a contiguous range rather than the whole index. It is also what makes
|
|
30
|
+
* indexing the same entry twice a rewrite instead of a duplicate row, which is
|
|
31
|
+
* what every driver's upsert relies on.
|
|
32
|
+
*/
|
|
33
|
+
export declare function primaryKey(dialect: DatabaseDialect): SqlFragment;
|
|
34
|
+
/**
|
|
35
|
+
* The filters that make a result legitimate.
|
|
36
|
+
*
|
|
37
|
+
* Every engine's `search` composes these before its own matching clause, and
|
|
38
|
+
* none of them is optional: the locale and the state are what stop a draft or
|
|
39
|
+
* another language reaching a reader who has no right to it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function scopeFilters(dialect: DatabaseDialect, scope: {
|
|
42
|
+
readonly locale: string;
|
|
43
|
+
readonly status: string;
|
|
44
|
+
readonly collections?: readonly string[];
|
|
45
|
+
}): SqlFragment[];
|
|
46
|
+
export declare function allOf(filters: readonly SqlFragment[]): SqlFragment;
|
|
47
|
+
/** The projected columns, in one place so the row type matches on all three. */
|
|
48
|
+
export declare function selectedColumns(dialect: DatabaseDialect): SqlFragment;
|
|
49
|
+
//# sourceMappingURL=table.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"table.d.ts","sourceRoot":"","sources":["../../src/search/table.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAc,KAAK,WAAW,EAAkB,MAAM,eAAe,CAAA;AAIlG;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,mBAAmB,CAAA;AAE5C,4DAA4D;AAC5D,eAAO,MAAM,gBAAgB,uBAAuB,CAAA;AAEpD,eAAO,MAAM,cAAc;aACzB,OAAO,EAAE,UAAU;aACnB,UAAU,EAAE,YAAY;aACxB,MAAM,EAAE,QAAQ;aAChB,MAAM,EAAE,QAAQ;aAChB,KAAK,EAAE,OAAO;IACd,yDAAyD;aACzD,OAAO,EAAE,SAAS;CACV,CAAA;AAWV,wBAAgB,WAAW,CAAC,OAAO,EAAE,eAAe,GAAG,WAAW,EAAE,CASnE;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,eAAe,GAAG,WAAW,CAGhE;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,OAAO,EAAE,eAAe,EACxB,KAAK,EAAE;IACL,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACzC,GACA,WAAW,EAAE,CAaf;AAED,wBAAgB,KAAK,CAAC,OAAO,EAAE,SAAS,WAAW,EAAE,GAAG,WAAW,CAElE;AAED,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,OAAO,EAAE,eAAe,GAAG,WAAW,CAWrE"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { identifier, sql, unsafeRaw } from '@cogenta/core';
|
|
2
|
+
import { textColumn, uuidColumn } from '../store/columns.js';
|
|
3
|
+
import { joinFragments, valueList } from '../store/fragments.js';
|
|
4
|
+
/**
|
|
5
|
+
* The physical index, shared by the three engines.
|
|
6
|
+
*
|
|
7
|
+
* One table for every collection rather than one per collection: a search box
|
|
8
|
+
* is site-wide, and a union across twenty collection tables would have to be
|
|
9
|
+
* rebuilt every time a schema changes. The engine-specific machinery — a
|
|
10
|
+
* `tsvector` column, a `FULLTEXT` key, an FTS5 twin — hangs off this same set
|
|
11
|
+
* of columns, so the filters and the row mapping are written once.
|
|
12
|
+
*
|
|
13
|
+
* The name is fixed rather than derived from a collection, so it is a constant
|
|
14
|
+
* and not a `naming.ts` function.
|
|
15
|
+
*/
|
|
16
|
+
export const SEARCH_TABLE = 'cogenta_search';
|
|
17
|
+
/** SQLite only: the FTS5 virtual table. See `sqlite.ts`. */
|
|
18
|
+
export const SEARCH_FTS_TABLE = 'cogenta_search_fts';
|
|
19
|
+
export const SEARCH_COLUMNS = {
|
|
20
|
+
entryId: 'entry_id',
|
|
21
|
+
collection: 'collection',
|
|
22
|
+
locale: 'locale',
|
|
23
|
+
status: 'status',
|
|
24
|
+
title: 'title',
|
|
25
|
+
/** The folded, extracted text. Never the stored JSON. */
|
|
26
|
+
content: 'content',
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `text` holds 64 KiB on MySQL, which a long article passes without trying.
|
|
30
|
+
* Silently truncating an article's tail out of the index is the kind of bug
|
|
31
|
+
* that is only ever noticed as "search does not find the end of my pages".
|
|
32
|
+
*/
|
|
33
|
+
function contentColumn(dialect) {
|
|
34
|
+
return unsafeRaw(dialect === 'mysql' ? 'longtext' : 'text');
|
|
35
|
+
}
|
|
36
|
+
export function baseColumns(dialect) {
|
|
37
|
+
return [
|
|
38
|
+
sql `${identifier(SEARCH_COLUMNS.entryId, dialect)} ${uuidColumn(dialect)} not null`,
|
|
39
|
+
sql `${identifier(SEARCH_COLUMNS.collection, dialect)} ${textColumn(dialect, 64)} not null`,
|
|
40
|
+
sql `${identifier(SEARCH_COLUMNS.locale, dialect)} ${textColumn(dialect, 16)} not null`,
|
|
41
|
+
sql `${identifier(SEARCH_COLUMNS.status, dialect)} ${textColumn(dialect, 16)} not null`,
|
|
42
|
+
sql `${identifier(SEARCH_COLUMNS.title, dialect)} ${textColumn(dialect, 500)} not null`,
|
|
43
|
+
sql `${identifier(SEARCH_COLUMNS.content, dialect)} ${contentColumn(dialect)} not null`,
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The collection leads the key so that clearing or re-indexing one collection
|
|
48
|
+
* touches a contiguous range rather than the whole index. It is also what makes
|
|
49
|
+
* indexing the same entry twice a rewrite instead of a duplicate row, which is
|
|
50
|
+
* what every driver's upsert relies on.
|
|
51
|
+
*/
|
|
52
|
+
export function primaryKey(dialect) {
|
|
53
|
+
return sql `primary key (${identifier(SEARCH_COLUMNS.collection, dialect)},
|
|
54
|
+
${identifier(SEARCH_COLUMNS.entryId, dialect)})`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The filters that make a result legitimate.
|
|
58
|
+
*
|
|
59
|
+
* Every engine's `search` composes these before its own matching clause, and
|
|
60
|
+
* none of them is optional: the locale and the state are what stop a draft or
|
|
61
|
+
* another language reaching a reader who has no right to it.
|
|
62
|
+
*/
|
|
63
|
+
export function scopeFilters(dialect, scope) {
|
|
64
|
+
const filters = [
|
|
65
|
+
sql `${identifier(SEARCH_COLUMNS.locale, dialect)} = ${scope.locale}`,
|
|
66
|
+
sql `${identifier(SEARCH_COLUMNS.status, dialect)} = ${scope.status}`,
|
|
67
|
+
];
|
|
68
|
+
if (scope.collections !== undefined && scope.collections.length > 0) {
|
|
69
|
+
filters.push(sql `${identifier(SEARCH_COLUMNS.collection, dialect)} in (${valueList([...scope.collections])})`);
|
|
70
|
+
}
|
|
71
|
+
return filters;
|
|
72
|
+
}
|
|
73
|
+
export function allOf(filters) {
|
|
74
|
+
return joinFragments(filters, ' and ');
|
|
75
|
+
}
|
|
76
|
+
/** The projected columns, in one place so the row type matches on all three. */
|
|
77
|
+
export function selectedColumns(dialect) {
|
|
78
|
+
return joinFragments([
|
|
79
|
+
identifier(SEARCH_COLUMNS.entryId, dialect),
|
|
80
|
+
identifier(SEARCH_COLUMNS.collection, dialect),
|
|
81
|
+
identifier(SEARCH_COLUMNS.locale, dialect),
|
|
82
|
+
identifier(SEARCH_COLUMNS.status, dialect),
|
|
83
|
+
identifier(SEARCH_COLUMNS.title, dialect),
|
|
84
|
+
], ', ');
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=table.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"table.js","sourceRoot":"","sources":["../../src/search/table.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalisation shared by the three engines.
|
|
3
|
+
*
|
|
4
|
+
* The reason it exists at all: accent handling is the one thing the three
|
|
5
|
+
* dialects disagree about that a *caller* would notice. Postgres folds accents
|
|
6
|
+
* only with the `unaccent` extension, which a managed instance may not allow;
|
|
7
|
+
* MySQL folds them through the collation, so `café` and `cafe` already match;
|
|
8
|
+
* SQLite's FTS5 unicode61 tokeniser folds them by default. Left alone, the same
|
|
9
|
+
* search would answer differently on each — the divergence the contract suite
|
|
10
|
+
* exists to prevent.
|
|
11
|
+
*
|
|
12
|
+
* So the text is folded **before** it reaches the database, on both the write
|
|
13
|
+
* and the read side, and every engine indexes the folded form. The cost is that
|
|
14
|
+
* Postgres stems `ete` rather than `été`; the gain is that "does searching for
|
|
15
|
+
* `cafe` find `Café` ?" has one answer everywhere, and that answer does not
|
|
16
|
+
* depend on an extension being installed.
|
|
17
|
+
*/
|
|
18
|
+
/** Lower-cased and stripped of diacritics. */
|
|
19
|
+
export declare function foldText(value: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* The words of a query or a document, folded.
|
|
22
|
+
*
|
|
23
|
+
* Splitting on anything that is not a letter or a digit is what makes the
|
|
24
|
+
* query safe to hand to three engines with three query languages: a token can
|
|
25
|
+
* never carry `+`, `-`, `*`, `"` or `(`, so no user input is ever read as a
|
|
26
|
+
* boolean-mode operator or an FTS5 expression.
|
|
27
|
+
*/
|
|
28
|
+
export declare function tokenize(value: string): string[];
|
|
29
|
+
/** Collapses runs of whitespace so the stored text has no accidental structure. */
|
|
30
|
+
export declare function condense(value: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Length below which MySQL ignores a word.
|
|
33
|
+
*
|
|
34
|
+
* InnoDB's `innodb_ft_min_token_size` defaults to 3, so `+ai*` in boolean mode
|
|
35
|
+
* matches nothing and, because of the `+`, makes the whole query match nothing.
|
|
36
|
+
* Dropping such tokens everywhere rather than only on MySQL keeps the three
|
|
37
|
+
* engines answering the same thing, which is worth more than two extra letters
|
|
38
|
+
* of precision on the two dialects that could have handled them.
|
|
39
|
+
*/
|
|
40
|
+
export declare const MIN_TOKEN_LENGTH = 3;
|
|
41
|
+
export declare function queryTokens(text: string): string[];
|
|
42
|
+
//# sourceMappingURL=text.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/search/text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,8CAA8C;AAC9C,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ9C;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAEhD;AAED,mFAAmF;AACnF,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,IAAI,CAAA;AAEjC,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAElD"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalisation shared by the three engines.
|
|
3
|
+
*
|
|
4
|
+
* The reason it exists at all: accent handling is the one thing the three
|
|
5
|
+
* dialects disagree about that a *caller* would notice. Postgres folds accents
|
|
6
|
+
* only with the `unaccent` extension, which a managed instance may not allow;
|
|
7
|
+
* MySQL folds them through the collation, so `café` and `cafe` already match;
|
|
8
|
+
* SQLite's FTS5 unicode61 tokeniser folds them by default. Left alone, the same
|
|
9
|
+
* search would answer differently on each — the divergence the contract suite
|
|
10
|
+
* exists to prevent.
|
|
11
|
+
*
|
|
12
|
+
* So the text is folded **before** it reaches the database, on both the write
|
|
13
|
+
* and the read side, and every engine indexes the folded form. The cost is that
|
|
14
|
+
* Postgres stems `ete` rather than `été`; the gain is that "does searching for
|
|
15
|
+
* `cafe` find `Café` ?" has one answer everywhere, and that answer does not
|
|
16
|
+
* depend on an extension being installed.
|
|
17
|
+
*/
|
|
18
|
+
/** Lower-cased and stripped of diacritics. */
|
|
19
|
+
export function foldText(value) {
|
|
20
|
+
// NFD splits `é` into `e` + combining acute, which the second step removes.
|
|
21
|
+
// `\p{Diacritic}` rather than a range: it also covers Greek, Vietnamese and
|
|
22
|
+
// the Hebrew points, which a Latin-1 range would leave accented.
|
|
23
|
+
return value
|
|
24
|
+
.normalize('NFD')
|
|
25
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The words of a query or a document, folded.
|
|
30
|
+
*
|
|
31
|
+
* Splitting on anything that is not a letter or a digit is what makes the
|
|
32
|
+
* query safe to hand to three engines with three query languages: a token can
|
|
33
|
+
* never carry `+`, `-`, `*`, `"` or `(`, so no user input is ever read as a
|
|
34
|
+
* boolean-mode operator or an FTS5 expression.
|
|
35
|
+
*/
|
|
36
|
+
export function tokenize(value) {
|
|
37
|
+
return foldText(value).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
38
|
+
}
|
|
39
|
+
/** Collapses runs of whitespace so the stored text has no accidental structure. */
|
|
40
|
+
export function condense(value) {
|
|
41
|
+
return value.replace(/\s+/gu, ' ').trim();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Length below which MySQL ignores a word.
|
|
45
|
+
*
|
|
46
|
+
* InnoDB's `innodb_ft_min_token_size` defaults to 3, so `+ai*` in boolean mode
|
|
47
|
+
* matches nothing and, because of the `+`, makes the whole query match nothing.
|
|
48
|
+
* Dropping such tokens everywhere rather than only on MySQL keeps the three
|
|
49
|
+
* engines answering the same thing, which is worth more than two extra letters
|
|
50
|
+
* of precision on the two dialects that could have handled them.
|
|
51
|
+
*/
|
|
52
|
+
export const MIN_TOKEN_LENGTH = 3;
|
|
53
|
+
export function queryTokens(text) {
|
|
54
|
+
return tokenize(text).filter((token) => token.length >= MIN_TOKEN_LENGTH);
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"text.js","sourceRoot":"","sources":["../../src/search/text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,8CAA8C;AAC9C,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,4EAA4E;IAC5E,4EAA4E;IAC5E,iEAAiE;IACjE,OAAO,KAAK;SACT,SAAS,CAAC,KAAK,CAAC;SAChB,OAAO,CAAC,iBAAiB,EAAE,EAAE,CAAC;SAC9B,WAAW,EAAE,CAAA;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC,IAAI,EAAE,CAAA;AACvD,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;AAC3C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,CAAA;AAEjC,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,IAAI,gBAAgB,CAAC,CAAA;AAC3E,CAAC"}
|