@byline/search-postgres 4.8.0 → 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/README.md CHANGED
@@ -1,19 +1,21 @@
1
1
  # @byline/search-postgres
2
2
 
3
- The built-in Postgres full-text `SearchProvider` for Byline CMS. Implements the
4
- `SearchProvider` seam from `@byline/core` over a weighted `tsvector` index —
5
- ranked search with **zero new infrastructure**, reusing your existing Postgres
6
- connection.
3
+ The built-in PostgreSQL full-text `SearchProvider` for Byline CMS. It combines
4
+ the portable multilingual analyzer from `@byline/search-analysis` with a
5
+ weighted, GIN-indexed `tsvector`, providing ranked search with **zero new
6
+ infrastructure** and reusing the existing PostgreSQL connection.
7
7
 
8
8
  It consumes the type-enriched `SearchDocument` that core assembles
9
9
  (`buildSearchDocument`) and stores one weighted row per
10
- `(collection_path, document_id, locale)`: `title`weight class `A`, `body`
11
- fields → `A`–`D` by their declared `boost`, facet **terms** → `C` (folded into
12
- the searchable vector), with facet **ids** and filterable scalars kept as
13
- `jsonb` for aggregation / filtering.
10
+ `(collection_path, document_id, locale)`: body fields → `A`–`D` by their
11
+ declared `boost`, facet **terms** → `C` (folded into the searchable vector),
12
+ with facet **ids** and filterable scalars kept as `jsonb` for future
13
+ aggregation and filtering. A collection's title remains display-only unless
14
+ its identity field is also declared in `search.body`.
14
15
 
15
- See [`docs/05-reading-and-delivery/07-search.md`](../../docs/05-reading-and-delivery/07-search.md)
16
- for the full subsystem design.
16
+ See
17
+ [`docs/06-search/06-postgres-and-mysql.md`](../../docs/06-search/06-postgres-and-mysql.md)
18
+ for the built-in provider comparison and operational reference.
17
19
 
18
20
  ## Install
19
21
 
@@ -54,6 +56,25 @@ migration stream — it ships its own numbered SQL files in
54
56
  `byline_search_migrations` table. There are three ways to apply them; pick per
55
57
  environment.
56
58
 
59
+ ### Portable-analysis cutover
60
+
61
+ The portable-analysis release replaces the original native PostgreSQL search
62
+ schema directly. There is no in-place compatibility migration: search data is
63
+ a disposable projection of published documents. Before deploying this version
64
+ over an older `@byline/search-postgres` installation, drop only the three
65
+ driver-owned tables, reapply `0001_init.sql`, and rebuild each searchable
66
+ collection:
67
+
68
+ ```sql
69
+ DROP TABLE IF EXISTS byline_search_index_metadata;
70
+ DROP TABLE IF EXISTS byline_search_documents;
71
+ DROP TABLE IF EXISTS byline_search_migrations;
72
+ ```
73
+
74
+ This does not remove CMS documents. After applying the new schema, run the
75
+ normal `client.reindex()` workflow so the portable index is reconstructed from
76
+ published versions.
77
+
57
78
  ### 1. Run the SQL by hand (locked-down / managed Postgres)
58
79
 
59
80
  The numbered files are the source of truth and are DBA-reviewable:
@@ -88,29 +109,36 @@ option 2 so startup is deterministic and DDL permissions are explicit.
88
109
  ```ts
89
110
  provider.capabilities
90
111
  // { facets: false, typoTolerance: false, semantic: false,
91
- // bm25: false, weighting: true, highlights: true }
112
+ // bm25: false, weighting: true, highlights: true,
113
+ // fullText: {
114
+ // nativeAnalysis: false, portableAnalysis: true,
115
+ // allTerms: true, anyTerms: true,
116
+ // minimumShouldMatch: true, phrase: true
117
+ // } }
92
118
  ```
93
119
 
94
- The `tsvector` + `ts_rank` floor: per-field **weighting** and **highlighting**
95
- (`ts_headline`) are supported today. Facet *data* is indexed, but facet
96
- *aggregation* queries, structured `where` filtering, fuzzy matching (`pg_trgm`),
97
- BM25 ranking, and semantic/vector retrieval are follow-ups surfaced honestly
98
- through the capability flags so consumers light up only what's available.
120
+ The `tsvector` + `ts_rank` floor supports per-field **weighting** and all shared
121
+ full-text matching policies. Ranked rows are highlighted from stored original
122
+ body text through the shared portable token offsets, so snippets preserve the
123
+ source spelling while matching normalized and expanded terms. Facet *data* is
124
+ indexed, but facet *aggregation* queries, structured `where` filtering, fuzzy
125
+ matching, BM25 ranking, and semantic/vector retrieval are follow-ups. The
126
+ capability flags let consumers enable only supported behavior.
99
127
 
100
128
  ## Language / locale
101
129
 
102
- Search is per-locale. Each document's text is indexed with the Postgres
103
- `regconfig` mapped from its content locale (`en` → `english`, `fr` → `french`,
104
- …), falling back to `simple` (unstemmed) for unmapped locales. Pass `locale`
105
- to `search()` so the query uses the matching `regconfig` (a locale-less query
106
- falls back to `simple` and won't match locale-stemmed vectors) or set
107
- `defaultLocale` so locale-less queries use your default content locale:
130
+ Search is stored per locale. The portable analyzer applies Unicode
131
+ normalization, ICU word segmentation, identifier preservation, optional
132
+ language expanders, and Han bigrams before the adapter writes parser-safe
133
+ physical terms to PostgreSQL. `defaultLocale` supplies the analyzer fallback
134
+ when content or a query does not declare a usable locale:
108
135
 
109
136
  ```ts
110
- postgresSearch({
111
- pool: db.pool,
112
- defaultLocale: 'en', // regconfig for searches that omit `locale`
113
- localeRegconfig: { th: 'thai' }, // a custom dictionary you've installed
114
- fallbackRegconfig: 'simple',
115
- })
137
+ postgresSearch({ pool: db.pool, defaultLocale: 'en' })
116
138
  ```
139
+
140
+ For language-specific stemming or lemmatization, construct a portable analyzer
141
+ with versioned expanders and pass it as `analyzer`. The provider persists its
142
+ fingerprint per collection. If that fingerprint changes, clear and rebuild the
143
+ affected collection before it accepts new writes or searches; this prevents
144
+ mixed token pipelines from producing incomplete results.
@@ -39,8 +39,9 @@ export interface IndexRow {
39
39
  export declare function weightClass(boost: number | undefined, defaultClass: WeightClass): WeightClass;
40
40
  /**
41
41
  * Transform a `SearchDocument` into the flat `IndexRow` the provider writes.
42
- * Pure — no SQL, no DB. The title always weights `A`; body text weights by
43
- * boost (default `B`); facet terms weight by boost (default `C`) and their
44
- * ids are projected into `facets`; filters are projected as-is.
42
+ * Pure — no SQL, no DB. `title` is display-only and never weighted (see the
43
+ * note below); body text weights by boost (default `B`); facet terms weight by
44
+ * boost (default `C`) and their ids are projected into `facets`; filters are
45
+ * projected as-is.
45
46
  */
46
47
  export declare function buildIndexRow(doc: SearchDocument): IndexRow;
@@ -22,9 +22,10 @@ export function weightClass(boost, defaultClass) {
22
22
  }
23
23
  /**
24
24
  * Transform a `SearchDocument` into the flat `IndexRow` the provider writes.
25
- * Pure — no SQL, no DB. The title always weights `A`; body text weights by
26
- * boost (default `B`); facet terms weight by boost (default `C`) and their
27
- * ids are projected into `facets`; filters are projected as-is.
25
+ * Pure — no SQL, no DB. `title` is display-only and never weighted (see the
26
+ * note below); body text weights by boost (default `B`); facet terms weight by
27
+ * boost (default `C`) and their ids are projected into `facets`; filters are
28
+ * projected as-is.
28
29
  */
29
30
  export function buildIndexRow(doc) {
30
31
  const weighted = { A: [], B: [], C: [], D: [] };
@@ -7,7 +7,6 @@
7
7
  */
8
8
  import { describe, expect, it } from 'vitest';
9
9
  import { buildIndexRow, weightClass } from './build-index-row.js';
10
- import { createRegconfigResolver } from './locale-regconfig.js';
11
10
  function doc(overrides = {}) {
12
11
  return {
13
12
  collectionPath: 'publications',
@@ -103,20 +102,3 @@ describe('buildIndexRow', () => {
103
102
  expect(row.body).toBe('Body text.\nEcology');
104
103
  });
105
104
  });
106
- describe('createRegconfigResolver', () => {
107
- const resolve = createRegconfigResolver();
108
- it('maps known locales (and their base) to a Postgres regconfig', () => {
109
- expect(resolve('en')).toBe('english');
110
- expect(resolve('fr')).toBe('french');
111
- expect(resolve('fr-CA')).toBe('french');
112
- });
113
- it('falls back to simple for unknown locales / undefined', () => {
114
- expect(resolve('th')).toBe('simple');
115
- expect(resolve(undefined)).toBe('simple');
116
- });
117
- it('honours overrides and a custom fallback', () => {
118
- const custom = createRegconfigResolver({ th: 'thai' }, 'english');
119
- expect(custom('th')).toBe('thai');
120
- expect(custom('xx')).toBe('english');
121
- });
122
- });
@@ -0,0 +1,14 @@
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 declare class SearchAnalyzerMismatchError extends Error {
9
+ readonly collectionPath: string;
10
+ readonly expectedFingerprint: string;
11
+ readonly actualFingerprint: string | null;
12
+ readonly code = "SEARCH_INDEX_REINDEX_REQUIRED";
13
+ constructor(collectionPath: string, expectedFingerprint: string, actualFingerprint: string | null);
14
+ }
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
+ const actual = actualFingerprint ?? 'an unversioned analyzer';
15
+ super(`Search index for collection "${collectionPath}" uses "${actual}"; ` +
16
+ `expected analyzer "${expectedFingerprint}". Rebuild this collection's search index.`);
17
+ this.collectionPath = collectionPath;
18
+ this.expectedFingerprint = expectedFingerprint;
19
+ this.actualFingerprint = actualFingerprint;
20
+ this.name = 'SearchAnalyzerMismatchError';
21
+ }
22
+ }
package/dist/index.d.ts CHANGED
@@ -27,9 +27,10 @@
27
27
  * ```
28
28
  */
29
29
  import type { SearchProvider } from '@byline/core';
30
+ import { type PortableSearchAnalyzer } from '@byline/search-analysis';
30
31
  import type { Pool } from 'pg';
31
32
  export { buildIndexRow, type IndexRow, type WeightClass, weightClass } from './build-index-row.js';
32
- export { createRegconfigResolver, DEFAULT_FALLBACK_REGCONFIG, type RegconfigResolver, } from './locale-regconfig.js';
33
+ export { SearchAnalyzerMismatchError } from './errors.js';
33
34
  export { type MigrateOptions, type MigrateResult, migrate } from './migrate.js';
34
35
  export { PostgresSearchProvider } from './postgres-search-provider.js';
35
36
  export interface PostgresSearchOptions {
@@ -47,22 +48,18 @@ export interface PostgresSearchOptions {
47
48
  */
48
49
  autoMigrate?: boolean;
49
50
  /**
50
- * Override or extend the locale Postgres `regconfig` (text-search
51
- * language) map. Merged over the built-in defaults.
51
+ * Locale used when neither indexed content nor a query supplies one.
52
+ * Defaults to `en`.
52
53
  */
53
- localeRegconfig?: Record<string, string>;
54
- /**
55
- * Fallback `regconfig` for locales not in the map. Defaults to `'simple'`
56
- * (no stemming / stop-words — unstemmed but correct).
57
- */
58
- fallbackRegconfig?: string;
54
+ defaultLocale?: string;
59
55
  /**
60
- * Locale used to choose the query text-search config when a `search()`
61
- * call omits `locale`. Set this to the host's default content locale so a
62
- * locale-less query matches default-locale documents (otherwise it falls
63
- * back to `simple` and won't match locale-stemmed vectors).
56
+ * Custom portable analyzer, typically carrying versioned language
57
+ * expanders. Defaults to `createPortableSearchAnalyzer({ defaultLocale })`.
58
+ *
59
+ * Changing the analyzer fingerprint requires rebuilding the affected
60
+ * collections before they can be searched or indexed.
64
61
  */
65
- defaultLocale?: string;
62
+ analyzer?: PortableSearchAnalyzer;
66
63
  /** Optional sink for migration progress lines (e.g. the host logger). */
67
64
  log?: (message: string) => void;
68
65
  }
package/dist/index.js CHANGED
@@ -5,11 +5,11 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import { createRegconfigResolver } from './locale-regconfig.js';
8
+ import { createPortableSearchAnalyzer } from '@byline/search-analysis';
9
9
  import { migrate } from './migrate.js';
10
10
  import { PostgresSearchProvider } from './postgres-search-provider.js';
11
11
  export { buildIndexRow, weightClass } from './build-index-row.js';
12
- export { createRegconfigResolver, DEFAULT_FALLBACK_REGCONFIG, } from './locale-regconfig.js';
12
+ export { SearchAnalyzerMismatchError } from './errors.js';
13
13
  export { migrate } from './migrate.js';
14
14
  export { PostgresSearchProvider } from './postgres-search-provider.js';
15
15
  /**
@@ -22,7 +22,7 @@ export { PostgresSearchProvider } from './postgres-search-provider.js';
22
22
  * `migrate(pool)` explicitly during boot instead.
23
23
  */
24
24
  export function postgresSearch(options) {
25
- const regconfig = createRegconfigResolver(options.localeRegconfig, options.fallbackRegconfig);
25
+ const analyzer = options.analyzer ?? createPortableSearchAnalyzer({ defaultLocale: options.defaultLocale });
26
26
  if (options.autoMigrate === true) {
27
27
  void migrate(options.pool, { log: options.log }).catch((error) => {
28
28
  const message = `[search-postgres] autoMigrate failed: ${error.message}`;
@@ -32,5 +32,5 @@ export function postgresSearch(options) {
32
32
  console.error(message);
33
33
  });
34
34
  }
35
- return new PostgresSearchProvider(options.pool, regconfig, options.defaultLocale);
35
+ return new PostgresSearchProvider(options.pool, analyzer);
36
36
  }
@@ -11,11 +11,11 @@ export const MIGRATIONS = [
11
11
  name: '0001_init.sql',
12
12
  sql: `-- @byline/search-postgres — 0001_init
13
13
  --
14
- -- The full-text search index, owned entirely by this driver. One row per
15
- -- (collection_path, document_id, locale). The \`search_vector\` is a weighted
16
- -- tsvector assembled from the type-enriched SearchDocument at upsert time
17
- -- (title => A, body fields => A–D by boost, facet terms => C). Facet ids and
18
- -- filterable scalars are kept as jsonb for aggregation / filtering.
14
+ -- The disposable full-text search index, owned entirely by this driver. One
15
+ -- row per (collection_path, document_id, locale). \`search_vector\` stores
16
+ -- parser-safe physical tokens produced by @byline/search-analysis and
17
+ -- \`analyzer_fingerprint\` identifies the exact portable analysis pipeline.
18
+ -- Search indexes created with an older schema must be dropped and rebuilt.
19
19
  --
20
20
  -- Idempotent (IF NOT EXISTS throughout) so re-applying is safe. The driver's
21
21
  -- migration runner records applied versions in byline_search_migrations.
@@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS byline_search_documents (
29
29
  title text NOT NULL DEFAULT '',
30
30
  path text,
31
31
  body text NOT NULL DEFAULT '',
32
- search_vector tsvector,
32
+ search_vector tsvector NOT NULL,
33
+ analyzer_fingerprint text NOT NULL,
33
34
  facets jsonb NOT NULL DEFAULT '{}'::jsonb,
34
35
  filters jsonb NOT NULL DEFAULT '{}'::jsonb,
35
36
  updated_at timestamptz NOT NULL DEFAULT now(),
@@ -51,6 +52,16 @@ CREATE INDEX IF NOT EXISTS byline_search_documents_facets_idx
51
52
  -- Single-collection scoping + status filtering.
52
53
  CREATE INDEX IF NOT EXISTS byline_search_documents_collection_idx
53
54
  ON byline_search_documents (collection_path, status);
55
+
56
+ -- One locked row per collection prevents concurrent processes with different
57
+ -- analyzer fingerprints from mixing incompatible projections. Collection
58
+ -- zones let fingerprint guards retain zone scope without scanning documents.
59
+ CREATE TABLE IF NOT EXISTS byline_search_index_metadata (
60
+ collection_path text PRIMARY KEY,
61
+ analyzer_fingerprint text NOT NULL,
62
+ zones text[] NOT NULL DEFAULT '{}',
63
+ updated_at timestamptz NOT NULL DEFAULT now()
64
+ );
54
65
  `,
55
66
  },
56
67
  ];
@@ -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 PortableSearchAnalyzer } from '@byline/search-analysis';
9
+ import type { IndexRow } from './build-index-row.js';
10
+ export interface PortableIndexVector {
11
+ /** PostgreSQL text representation accepted by `$n::tsvector`. */
12
+ value: string;
13
+ analyzerFingerprint: string;
14
+ }
15
+ /**
16
+ * Build a weighted tsvector literal from portable logical terms. Expansions
17
+ * share their source position, derived variants lose one weight class, and
18
+ * Han grams always use the lowest class.
19
+ */
20
+ export declare function buildPortableIndexVector(row: Pick<IndexRow, 'locale' | 'weighted'>, analyzer: PortableSearchAnalyzer): PortableIndexVector;
@@ -0,0 +1,108 @@
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 { encodeSqlToken, } from '@byline/search-analysis';
9
+ const WEIGHT_CLASSES = ['A', 'B', 'C', 'D'];
10
+ const MAX_TSVECTOR_POSITION = 16_383;
11
+ /**
12
+ * Build a weighted tsvector literal from portable logical terms. Expansions
13
+ * share their source position, derived variants lose one weight class, and
14
+ * Han grams always use the lowest class.
15
+ */
16
+ export function buildPortableIndexVector(row, analyzer) {
17
+ const lexemes = new Map();
18
+ let positionBase = 0;
19
+ for (const weight of WEIGHT_CLASSES) {
20
+ const analyzed = analyzer.analyzeText({ text: row.weighted[weight], locale: row.locale });
21
+ const sourcePositionCount = Math.max(-1, ...analyzed.exactTokens.map(positionOf), ...analyzed.identifierTokens.map(positionOf)) + 1;
22
+ const gramPositions = positionGrams(analyzed.gramTokens, positionBase + sourcePositionCount + 1);
23
+ for (const token of analyzed.tokens) {
24
+ const physical = encodeSqlToken(token);
25
+ const position = gramPositions.get(token) ?? positionBase + token.position + 1;
26
+ const effectiveWeight = tokenWeight(token, weight);
27
+ addLexeme(lexemes, physical, position, effectiveWeight);
28
+ }
29
+ // Keep a one-position boundary between weight buckets so phrases cannot
30
+ // bridge independently configured fields solely because they were joined.
31
+ positionBase = Math.max(positionBase + sourcePositionCount, ...gramPositions.values()) + 1;
32
+ }
33
+ return {
34
+ value: renderTsvector(lexemes),
35
+ analyzerFingerprint: analyzer.fingerprint,
36
+ };
37
+ }
38
+ function positionOf(token) {
39
+ return token.position;
40
+ }
41
+ function positionGrams(grams, firstPosition) {
42
+ const positioned = new Map();
43
+ const ordered = grams.toSorted((left, right) => left.normalizedStart - right.normalizedStart || left.normalizedEnd - right.normalizedEnd);
44
+ let position = firstPosition;
45
+ let previous;
46
+ for (const gram of ordered) {
47
+ // Overlapping bigrams in one Han run occupy consecutive positions.
48
+ // Leave a gap between distinct runs so a phrase cannot bridge intervening
49
+ // non-Han content.
50
+ if (previous != null && gram.normalizedStart >= previous.normalizedEnd)
51
+ position += 1;
52
+ positioned.set(gram, position);
53
+ position += 1;
54
+ previous = gram;
55
+ }
56
+ return positioned;
57
+ }
58
+ function tokenWeight(token, sourceWeight) {
59
+ if (token.kind === 'gram')
60
+ return 'D';
61
+ if (token.kind === 'exact' || token.kind === 'identifier')
62
+ return sourceWeight;
63
+ return lowerWeight(sourceWeight);
64
+ }
65
+ function lowerWeight(weight) {
66
+ switch (weight) {
67
+ case 'A':
68
+ return 'B';
69
+ case 'B':
70
+ return 'C';
71
+ case 'C':
72
+ case 'D':
73
+ return 'D';
74
+ }
75
+ }
76
+ function addLexeme(lexemes, lexeme, position, weight) {
77
+ let entry = lexemes.get(lexeme);
78
+ if (entry == null) {
79
+ entry = { positions: new Map(), beyondPositionLimit: false };
80
+ lexemes.set(lexeme, entry);
81
+ }
82
+ if (position > MAX_TSVECTOR_POSITION) {
83
+ entry.beyondPositionLimit = true;
84
+ return;
85
+ }
86
+ const current = entry.positions.get(position);
87
+ if (current == null || weightRank(weight) > weightRank(current)) {
88
+ entry.positions.set(position, weight);
89
+ }
90
+ }
91
+ function weightRank(weight) {
92
+ return WEIGHT_CLASSES.length - WEIGHT_CLASSES.indexOf(weight);
93
+ }
94
+ function renderTsvector(lexemes) {
95
+ return [...lexemes.entries()]
96
+ .sort(([left], [right]) => left.localeCompare(right))
97
+ .map(([lexeme, entry]) => {
98
+ const positions = [...entry.positions.entries()]
99
+ .sort(([left], [right]) => left - right)
100
+ .map(([position, weight]) => `${position}${weight}`);
101
+ // A bare lexeme still participates in matching when all of its
102
+ // occurrences exceed PostgreSQL's positional ceiling.
103
+ if (positions.length === 0 && entry.beyondPositionLimit)
104
+ return `'${lexeme}'`;
105
+ return positions.length > 0 ? `'${lexeme}':${positions.join(',')}` : `'${lexeme}'`;
106
+ })
107
+ .join(' ');
108
+ }
@@ -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,61 @@
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, encodeSqlToken, } from '@byline/search-analysis';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { buildPortableIndexVector } from './portable-index-vector.js';
11
+ const englishExpander = {
12
+ fingerprint: 'english-test1',
13
+ supports: (locale) => locale.startsWith('en'),
14
+ expand: (token) => (token.value === 'running' ? [{ kind: 'stem', value: 'run' }] : []),
15
+ };
16
+ function row(overrides = {}) {
17
+ return {
18
+ collectionPath: 'publications',
19
+ documentId: 'doc-1',
20
+ locale: 'en',
21
+ status: 'published',
22
+ zones: ['site'],
23
+ title: 'Running database',
24
+ path: 'running-database',
25
+ body: 'Running 数据库',
26
+ weighted: { A: 'Running 数据库', B: '', C: '', D: '' },
27
+ facets: {},
28
+ filters: {},
29
+ updatedAt: '2026-07-26T00:00:00.000Z',
30
+ ...overrides,
31
+ };
32
+ }
33
+ describe('buildPortableIndexVector', () => {
34
+ it('keeps exact and derived terms at one position with lower derived weight', () => {
35
+ const analyzer = createPortableSearchAnalyzer({ expanders: [englishExpander] });
36
+ const vector = buildPortableIndexVector(row(), analyzer);
37
+ const exact = encodeSqlToken({ kind: 'exact', value: 'running' });
38
+ const stem = encodeSqlToken({ kind: 'stem', value: 'run' });
39
+ expect(vector.value).toContain(`'${exact}':1A`);
40
+ expect(vector.value).toContain(`'${stem}':1B`);
41
+ expect(vector.analyzerFingerprint).toBe(analyzer.fingerprint);
42
+ });
43
+ it('always assigns Han grams to weight D', () => {
44
+ const analyzer = createPortableSearchAnalyzer();
45
+ const vector = buildPortableIndexVector(row(), analyzer);
46
+ expect(vector.value).toContain(`'${encodeSqlToken({ kind: 'gram', value: '数据' })}':4D`);
47
+ expect(vector.value).toContain(`'${encodeSqlToken({ kind: 'gram', value: '据库' })}':5D`);
48
+ });
49
+ it('separates weight buckets positionally', () => {
50
+ const analyzer = createPortableSearchAnalyzer();
51
+ const input = row({
52
+ body: 'alpha\nbeta',
53
+ weighted: { A: 'alpha', B: 'beta', C: '', D: '' },
54
+ });
55
+ const vector = buildPortableIndexVector(input, analyzer);
56
+ const alpha = encodeSqlToken({ kind: 'exact', value: 'alpha' });
57
+ const beta = encodeSqlToken({ kind: 'exact', value: 'beta' });
58
+ expect(vector.value).toContain(`'${alpha}':1A`);
59
+ expect(vector.value).toContain(`'${beta}':3B`);
60
+ });
61
+ });
@@ -0,0 +1,17 @@
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 PortableQueryPlan } from '@byline/search-analysis';
9
+ export interface PortablePostgresQuery {
10
+ /** Complete matching/ranking tsquery source for `to_tsquery('simple', …)`. */
11
+ tsquery: string;
12
+ /** One query per source concept, used to enforce minimum-should-match. */
13
+ conceptTsqueries: string[];
14
+ minimumShouldMatch?: number;
15
+ }
16
+ /** Translate a backend-neutral plan into parser-safe PostgreSQL tsquery text. */
17
+ export declare function buildPortablePostgresQuery(plan: PortableQueryPlan): PortablePostgresQuery;
@@ -0,0 +1,110 @@
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 { encodeSqlToken, } from '@byline/search-analysis';
9
+ /** Translate a backend-neutral plan into parser-safe PostgreSQL tsquery text. */
10
+ export function buildPortablePostgresQuery(plan) {
11
+ const conceptTsqueries = plan.concepts.map(conceptExpression);
12
+ if (conceptTsqueries.some((expression) => expression.length === 0)) {
13
+ throw new TypeError('Portable query plan contains a concept with no searchable terms');
14
+ }
15
+ if (conceptTsqueries.length === 0) {
16
+ return { tsquery: '', conceptTsqueries: [] };
17
+ }
18
+ const operator = plan.matching.operator === 'all' ? '&' : '|';
19
+ const groupedConcepts = groupCrossConceptGrams(plan, conceptTsqueries, operator);
20
+ const clauses = [joinExpressions(groupedConcepts, operator)];
21
+ for (const phrase of plan.phrases) {
22
+ const expressions = phrase.conceptIndexes.map((index) => conceptTsqueries[index] ?? '');
23
+ if (expressions.some((expression) => expression.length === 0)) {
24
+ throw new RangeError('Portable query phrase references an unknown concept');
25
+ }
26
+ clauses.push(joinExpressions(expressions, '<->'));
27
+ }
28
+ return {
29
+ tsquery: joinExpressions(clauses, '&'),
30
+ conceptTsqueries,
31
+ ...(plan.matching.minimumShouldMatch != null
32
+ ? { minimumShouldMatch: plan.matching.minimumShouldMatch }
33
+ : {}),
34
+ };
35
+ }
36
+ function conceptExpression(concept) {
37
+ const alternatives = uniqueTerms([
38
+ ...concept.exactTokens,
39
+ ...concept.stemTokens,
40
+ ...concept.lemmaTokens,
41
+ ...concept.normalizedTokens,
42
+ ...concept.identifierTokens,
43
+ ]);
44
+ const grams = orderedTerms(concept.gramTokens);
45
+ if (grams.length > 0)
46
+ alternatives.push(joinExpressions(grams, '<->'));
47
+ return joinExpressions(alternatives, '|');
48
+ }
49
+ function groupCrossConceptGrams(plan, concepts, operator) {
50
+ const replacements = new Map();
51
+ for (const sequence of plan.gramSequences) {
52
+ if (sequence.length === 0)
53
+ continue;
54
+ const start = Math.min(...sequence.map((token) => token.normalizedStart));
55
+ const end = Math.max(...sequence.map((token) => token.normalizedEnd));
56
+ const indexes = plan.concepts
57
+ .filter((concept) => conceptOverlaps(concept, start, end))
58
+ .map((concept) => concept.index);
59
+ if (indexes.length < 2)
60
+ continue;
61
+ const first = indexes[0];
62
+ const last = indexes.at(-1);
63
+ if (first == null || last == null || last - first + 1 !== indexes.length)
64
+ continue;
65
+ const original = joinExpressions(indexes.map((index) => concepts[index] ?? ''), operator);
66
+ const gramPhrase = joinExpressions(orderedTerms(sequence), '<->');
67
+ if (original.length === 0 || gramPhrase.length === 0)
68
+ continue;
69
+ replacements.set(first, {
70
+ expression: joinExpressions([original, gramPhrase], '|'),
71
+ through: last,
72
+ });
73
+ }
74
+ const grouped = [];
75
+ for (let index = 0; index < concepts.length; index++) {
76
+ const replacement = replacements.get(index);
77
+ if (replacement != null) {
78
+ grouped.push(replacement.expression);
79
+ index = replacement.through;
80
+ continue;
81
+ }
82
+ if ([...replacements.entries()].some(([start, entry]) => index > start && index <= entry.through)) {
83
+ continue;
84
+ }
85
+ const concept = concepts[index];
86
+ if (concept != null)
87
+ grouped.push(concept);
88
+ }
89
+ return grouped;
90
+ }
91
+ function conceptOverlaps(concept, start, end) {
92
+ const source = [...concept.exactTokens, ...concept.identifierTokens];
93
+ return source.some((token) => token.normalizedStart < end && token.normalizedEnd > start);
94
+ }
95
+ function uniqueTerms(tokens) {
96
+ return [...new Set(tokens.map((token) => encodeSqlToken(token)))];
97
+ }
98
+ function orderedTerms(tokens) {
99
+ return tokens
100
+ .toSorted((left, right) => left.normalizedStart - right.normalizedStart || left.normalizedEnd - right.normalizedEnd)
101
+ .map((token) => encodeSqlToken(token));
102
+ }
103
+ function joinExpressions(expressions, operator) {
104
+ const nonEmpty = expressions.filter((expression) => expression.length > 0);
105
+ if (nonEmpty.length === 0)
106
+ return '';
107
+ if (nonEmpty.length === 1)
108
+ return nonEmpty[0] ?? '';
109
+ return `(${nonEmpty.map((expression) => `(${expression})`).join(` ${operator} `)})`;
110
+ }
@@ -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,48 @@
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, encodeSqlToken, } from '@byline/search-analysis';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { buildPortablePostgresQuery } from './portable-query.js';
11
+ const expander = {
12
+ fingerprint: 'english-test1',
13
+ supports: (locale) => locale.startsWith('en'),
14
+ expand: (token) => (token.value === 'running' ? [{ kind: 'stem', value: 'run' }] : []),
15
+ };
16
+ describe('buildPortablePostgresQuery', () => {
17
+ it('keeps recall variants grouped inside all-concept matching', () => {
18
+ const analyzer = createPortableSearchAnalyzer({ expanders: [expander] });
19
+ const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({ query: 'running restoration', locale: 'en' }));
20
+ const exact = encodeSqlToken({ kind: 'exact', value: 'running' });
21
+ const stem = encodeSqlToken({ kind: 'stem', value: 'run' });
22
+ const restoration = encodeSqlToken({ kind: 'exact', value: 'restoration' });
23
+ expect(translated.conceptTsqueries).toHaveLength(2);
24
+ expect(translated.conceptTsqueries[0]).toContain(exact);
25
+ expect(translated.conceptTsqueries[0]).toContain(stem);
26
+ expect(translated.tsquery).toContain(' | ');
27
+ expect(translated.tsquery).toContain(' & ');
28
+ expect(translated.tsquery).toContain(restoration);
29
+ });
30
+ it('preserves phrase order and minimum-should-match inputs', () => {
31
+ const analyzer = createPortableSearchAnalyzer();
32
+ const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({
33
+ query: '"forest restoration" database',
34
+ locale: 'en',
35
+ matching: { operator: 'any', minimumShouldMatch: 2 },
36
+ }));
37
+ expect(translated.conceptTsqueries).toHaveLength(3);
38
+ expect(translated.minimumShouldMatch).toBe(2);
39
+ expect(translated.tsquery).toContain(' | ');
40
+ expect(translated.tsquery).toContain(' <-> ');
41
+ expect(translated.tsquery).toMatch(/^[a-z0-9()|&<>\-\s]+$/);
42
+ });
43
+ it('returns an empty translation for punctuation-only input', () => {
44
+ const analyzer = createPortableSearchAnalyzer();
45
+ const translated = buildPortablePostgresQuery(analyzer.analyzeQuery({ query: '---' }));
46
+ expect(translated).toEqual({ tsquery: '', conceptTsqueries: [] });
47
+ });
48
+ });
@@ -6,32 +6,17 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { SearchCapabilities, SearchDocument, SearchProvider, SearchQuery, SearchResults } from '@byline/core';
9
+ import { type PortableSearchAnalyzer } from '@byline/search-analysis';
9
10
  import type { Pool } from 'pg';
10
- import type { RegconfigResolver } from './locale-regconfig.js';
11
11
  /**
12
- * The built-in Postgres full-text `SearchProvider`. Stores one weighted
13
- * `tsvector` row per `(collection_path, document_id, locale)` and ranks with
14
- * `websearch_to_tsquery` + `ts_rank`. Owns its schema (see `migrate`).
12
+ * The built-in PostgreSQL `SearchProvider`. Stores portable logical terms in
13
+ * one weighted tsvector per `(collection_path, document_id, locale)`.
15
14
  */
16
15
  export declare class PostgresSearchProvider implements SearchProvider {
17
16
  private readonly pool;
18
- private readonly regconfig;
19
- /**
20
- * Locale used to pick the query `regconfig` when a search omits `locale`.
21
- * Without it, a locale-less query falls back to `simple` (unstemmed) and
22
- * silently fails to match locale-stemmed vectors. Set to the host's
23
- * default content locale.
24
- */
25
- private readonly defaultLocale?;
17
+ private readonly analyzer;
26
18
  readonly capabilities: SearchCapabilities;
27
- constructor(pool: Pool, regconfig: RegconfigResolver,
28
- /**
29
- * Locale used to pick the query `regconfig` when a search omits `locale`.
30
- * Without it, a locale-less query falls back to `simple` (unstemmed) and
31
- * silently fails to match locale-stemmed vectors. Set to the host's
32
- * default content locale.
33
- */
34
- defaultLocale?: string | undefined);
19
+ constructor(pool: Pool, analyzer: PortableSearchAnalyzer);
35
20
  upsert(doc: SearchDocument): Promise<void>;
36
21
  remove(ref: {
37
22
  collectionPath: string;
@@ -42,4 +27,5 @@ export declare class PostgresSearchProvider implements SearchProvider {
42
27
  collectionPath?: string;
43
28
  }): Promise<void>;
44
29
  search(query: SearchQuery): Promise<SearchResults>;
30
+ private assertAnalyzerFingerprint;
45
31
  }
@@ -5,78 +5,72 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { highlightPortableText } from '@byline/search-analysis';
8
9
  import { buildIndexRow } from './build-index-row.js';
10
+ import { SearchAnalyzerMismatchError } from './errors.js';
11
+ import { buildPortableIndexVector } from './portable-index-vector.js';
12
+ import { buildPortablePostgresQuery } from './portable-query.js';
9
13
  const CAPABILITIES = {
10
- // tsvector + ts_rank floor: no IDF, no fuzzy, no vectors. Facet aggregation
11
- // (the data is indexed) and structured `where` filtering are follow-ups.
14
+ // Portable tsvector + ts_rank floor: no IDF, fuzzy matching, vectors,
15
+ // aggregation, or structured `where` filtering yet.
12
16
  facets: false,
13
17
  typoTolerance: false,
14
18
  semantic: false,
15
19
  bm25: false,
16
20
  weighting: true,
17
21
  highlights: true,
22
+ fullText: {
23
+ nativeAnalysis: false,
24
+ portableAnalysis: true,
25
+ allTerms: true,
26
+ anyTerms: true,
27
+ minimumShouldMatch: true,
28
+ phrase: true,
29
+ },
18
30
  };
19
31
  /**
20
- * The built-in Postgres full-text `SearchProvider`. Stores one weighted
21
- * `tsvector` row per `(collection_path, document_id, locale)` and ranks with
22
- * `websearch_to_tsquery` + `ts_rank`. Owns its schema (see `migrate`).
32
+ * The built-in PostgreSQL `SearchProvider`. Stores portable logical terms in
33
+ * one weighted tsvector per `(collection_path, document_id, locale)`.
23
34
  */
24
35
  export class PostgresSearchProvider {
25
36
  pool;
26
- regconfig;
27
- defaultLocale;
37
+ analyzer;
28
38
  capabilities = CAPABILITIES;
29
- constructor(pool, regconfig,
30
- /**
31
- * Locale used to pick the query `regconfig` when a search omits `locale`.
32
- * Without it, a locale-less query falls back to `simple` (unstemmed) and
33
- * silently fails to match locale-stemmed vectors. Set to the host's
34
- * default content locale.
35
- */
36
- defaultLocale) {
39
+ constructor(pool, analyzer) {
37
40
  this.pool = pool;
38
- this.regconfig = regconfig;
39
- this.defaultLocale = defaultLocale;
41
+ this.analyzer = analyzer;
40
42
  }
41
43
  async upsert(doc) {
42
44
  const row = buildIndexRow(doc);
43
- const cfg = this.regconfig(row.locale);
44
- await this.pool.query(`INSERT INTO byline_search_documents
45
- (collection_path, document_id, locale, status, zones, title, path, body,
46
- search_vector, facets, filters, updated_at)
47
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8,
48
- setweight(to_tsvector($9::regconfig, $10), 'A') ||
49
- setweight(to_tsvector($9::regconfig, $11), 'B') ||
50
- setweight(to_tsvector($9::regconfig, $12), 'C') ||
51
- setweight(to_tsvector($9::regconfig, $13), 'D'),
52
- $14::jsonb, $15::jsonb, $16)
53
- ON CONFLICT (collection_path, document_id, locale) DO UPDATE SET
54
- status = EXCLUDED.status,
55
- zones = EXCLUDED.zones,
56
- title = EXCLUDED.title,
57
- path = EXCLUDED.path,
58
- body = EXCLUDED.body,
59
- search_vector = EXCLUDED.search_vector,
60
- facets = EXCLUDED.facets,
61
- filters = EXCLUDED.filters,
62
- updated_at = EXCLUDED.updated_at`, [
63
- row.collectionPath,
64
- row.documentId,
65
- row.locale,
66
- row.status,
67
- row.zones,
68
- row.title,
69
- row.path,
70
- row.body,
71
- cfg,
72
- row.weighted.A,
73
- row.weighted.B,
74
- row.weighted.C,
75
- row.weighted.D,
76
- JSON.stringify(row.facets),
77
- JSON.stringify(row.filters),
78
- row.updatedAt,
79
- ]);
45
+ const vector = buildPortableIndexVector(row, this.analyzer);
46
+ const client = await this.pool.connect();
47
+ try {
48
+ await client.query('BEGIN');
49
+ await client.query(`INSERT INTO byline_search_index_metadata
50
+ (collection_path, analyzer_fingerprint, zones, updated_at)
51
+ VALUES ($1, $2, $3, now())
52
+ ON CONFLICT (collection_path) DO UPDATE
53
+ SET zones = ARRAY(
54
+ SELECT DISTINCT unnest(byline_search_index_metadata.zones || EXCLUDED.zones)
55
+ )`, [row.collectionPath, vector.analyzerFingerprint, row.zones]);
56
+ const metadata = await client.query(`SELECT analyzer_fingerprint
57
+ FROM byline_search_index_metadata
58
+ WHERE collection_path = $1
59
+ FOR UPDATE`, [row.collectionPath]);
60
+ const actualFingerprint = metadata.rows[0]?.analyzer_fingerprint ?? null;
61
+ if (actualFingerprint !== vector.analyzerFingerprint) {
62
+ throw new SearchAnalyzerMismatchError(row.collectionPath, vector.analyzerFingerprint, actualFingerprint);
63
+ }
64
+ await client.query(UPSERT_SQL, upsertParams(row, vector.value, vector.analyzerFingerprint));
65
+ await client.query('COMMIT');
66
+ }
67
+ catch (error) {
68
+ await rollback(client);
69
+ throw error;
70
+ }
71
+ finally {
72
+ client.release();
73
+ }
80
74
  }
81
75
  async remove(ref) {
82
76
  if (ref.locale != null) {
@@ -89,68 +83,171 @@ export class PostgresSearchProvider {
89
83
  }
90
84
  }
91
85
  async reindex(opts = {}) {
92
- // Clear the slice so a rebuild drops orphans (rows for deleted documents);
93
- // the caller (client.reindex) then re-upserts the live published set.
94
- if (opts.collectionPath != null) {
95
- await this.pool.query('DELETE FROM byline_search_documents WHERE collection_path = $1', [
96
- opts.collectionPath,
97
- ]);
86
+ const client = await this.pool.connect();
87
+ try {
88
+ await client.query('BEGIN');
89
+ if (opts.collectionPath != null) {
90
+ await client.query('DELETE FROM byline_search_index_metadata WHERE collection_path = $1', [
91
+ opts.collectionPath,
92
+ ]);
93
+ await client.query('DELETE FROM byline_search_documents WHERE collection_path = $1', [
94
+ opts.collectionPath,
95
+ ]);
96
+ }
97
+ else {
98
+ await client.query('TRUNCATE byline_search_documents');
99
+ await client.query('TRUNCATE byline_search_index_metadata');
100
+ }
101
+ await client.query('COMMIT');
98
102
  }
99
- else {
100
- await this.pool.query('TRUNCATE byline_search_documents');
103
+ catch (error) {
104
+ await rollback(client);
105
+ throw error;
106
+ }
107
+ finally {
108
+ client.release();
101
109
  }
102
110
  }
103
111
  async search(query) {
104
- const cfg = this.regconfig(query.locale ?? this.defaultLocale);
105
- const limit = query.limit ?? 20;
106
- const offset = query.offset ?? 0;
107
- // $1 = regconfig, $2 = query string; further binds appended below.
108
- const params = [cfg, query.query];
112
+ const plan = this.analyzer.analyzeQuery({
113
+ query: query.query,
114
+ locale: query.locale,
115
+ matching: query.matching,
116
+ });
117
+ const translated = buildPortablePostgresQuery(plan);
118
+ if (translated.tsquery.length === 0)
119
+ return { hits: [], total: 0 };
120
+ await this.assertAnalyzerFingerprint(query);
121
+ const params = [translated.tsquery];
109
122
  const where = ['d.search_vector @@ q.query'];
110
- if (query.collectionPath != null) {
111
- params.push(query.collectionPath);
112
- where.push(`d.collection_path = $${params.length}`);
113
- }
114
- if (query.zone != null) {
115
- params.push([query.zone]);
116
- where.push(`d.zones @> $${params.length}`);
117
- }
118
- if (query.locale != null) {
119
- params.push(query.locale);
120
- where.push(`d.locale = $${params.length}`);
121
- }
122
- // Default to published-only; 'any' is the admin escape hatch.
123
- if (query.status !== 'any') {
124
- params.push('published');
125
- where.push(`d.status = $${params.length}`);
123
+ if (translated.minimumShouldMatch != null) {
124
+ params.push(translated.conceptTsqueries);
125
+ const conceptsParam = params.length;
126
+ params.push(translated.minimumShouldMatch);
127
+ const minimumParam = params.length;
128
+ where.push(`(SELECT count(*)
129
+ FROM unnest($${conceptsParam}::text[]) AS concept(query_text)
130
+ WHERE d.search_vector @@ to_tsquery('simple', concept.query_text))
131
+ >= $${minimumParam}`);
126
132
  }
133
+ appendScopeFilters(query, params, where, true);
134
+ const cte = `WITH q AS (SELECT to_tsquery('simple', $1) AS query)`;
127
135
  const whereSql = where.join(' AND ');
128
- const cte = `WITH q AS (SELECT websearch_to_tsquery($1::regconfig, $2) AS query)`;
129
136
  const countResult = await this.pool.query(`${cte}
130
137
  SELECT count(*)::text AS total
131
138
  FROM byline_search_documents d, q
132
139
  WHERE ${whereSql}`, params);
133
140
  const total = Number(countResult.rows[0]?.total ?? 0);
141
+ const limit = query.limit ?? 20;
142
+ const offset = query.offset ?? 0;
134
143
  const limitParam = params.length + 1;
135
144
  const offsetParam = params.length + 2;
136
145
  const hitResult = await this.pool.query(`${cte}
137
- SELECT d.collection_path, d.document_id, d.locale, d.title, d.path,
138
- ts_rank(d.search_vector, q.query) AS score,
139
- ts_headline($1::regconfig, d.body, q.query,
140
- 'StartSel=<mark>, StopSel=</mark>, MaxFragments=2, MaxWords=24, MinWords=8') AS highlight
146
+ SELECT d.collection_path, d.document_id, d.locale, d.title, d.path, d.body,
147
+ ts_rank(d.search_vector, q.query) AS score
141
148
  FROM byline_search_documents d, q
142
149
  WHERE ${whereSql}
143
- ORDER BY score DESC, d.updated_at DESC
150
+ ORDER BY score DESC, d.updated_at DESC,
151
+ d.collection_path, d.document_id, d.locale
144
152
  LIMIT $${limitParam} OFFSET $${offsetParam}`, [...params, limit, offset]);
145
- const hits = hitResult.rows.map((r) => ({
146
- collectionPath: r.collection_path,
147
- documentId: r.document_id,
148
- locale: r.locale,
149
- title: r.title,
150
- path: r.path,
151
- score: Number(r.score),
152
- highlights: r.highlight ? { body: [r.highlight] } : undefined,
153
- }));
153
+ const hits = hitResult.rows.map((row) => {
154
+ const highlight = highlightPortableText({
155
+ text: row.body,
156
+ plan,
157
+ analyzer: this.analyzer,
158
+ });
159
+ return {
160
+ collectionPath: row.collection_path,
161
+ documentId: row.document_id,
162
+ locale: row.locale,
163
+ title: row.title,
164
+ path: row.path,
165
+ score: Number(row.score),
166
+ ...(highlight == null ? {} : { highlights: { body: [highlight] } }),
167
+ };
168
+ });
154
169
  return { hits, total };
155
170
  }
171
+ async assertAnalyzerFingerprint(query) {
172
+ const params = [this.analyzer.fingerprint];
173
+ const where = ['m.analyzer_fingerprint IS DISTINCT FROM $1'];
174
+ appendMetadataScopeFilters(query, params, where);
175
+ const incompatible = await this.pool.query(`SELECT m.collection_path, m.analyzer_fingerprint
176
+ FROM byline_search_index_metadata m
177
+ WHERE ${where.join(' AND ')}
178
+ LIMIT 1`, params);
179
+ const row = incompatible.rows[0];
180
+ if (row != null) {
181
+ throw new SearchAnalyzerMismatchError(row.collection_path, this.analyzer.fingerprint, row.analyzer_fingerprint);
182
+ }
183
+ }
184
+ }
185
+ function appendMetadataScopeFilters(query, params, where) {
186
+ if (query.collectionPath != null) {
187
+ params.push(query.collectionPath);
188
+ where.push(`m.collection_path = $${params.length}`);
189
+ }
190
+ if (query.zone != null) {
191
+ params.push([query.zone]);
192
+ where.push(`m.zones @> $${params.length}`);
193
+ }
194
+ }
195
+ function appendScopeFilters(query, params, where, includeStatus) {
196
+ if (query.collectionPath != null) {
197
+ params.push(query.collectionPath);
198
+ where.push(`d.collection_path = $${params.length}`);
199
+ }
200
+ if (query.zone != null) {
201
+ params.push([query.zone]);
202
+ where.push(`d.zones @> $${params.length}`);
203
+ }
204
+ if (query.locale != null) {
205
+ params.push(query.locale);
206
+ where.push(`d.locale = $${params.length}`);
207
+ }
208
+ if (includeStatus && query.status !== 'any') {
209
+ params.push('published');
210
+ where.push(`d.status = $${params.length}`);
211
+ }
212
+ }
213
+ function upsertParams(row, vector, analyzerFingerprint) {
214
+ return [
215
+ row.collectionPath,
216
+ row.documentId,
217
+ row.locale,
218
+ row.status,
219
+ row.zones,
220
+ row.title,
221
+ row.path,
222
+ row.body,
223
+ vector,
224
+ analyzerFingerprint,
225
+ JSON.stringify(row.facets),
226
+ JSON.stringify(row.filters),
227
+ row.updatedAt,
228
+ ];
229
+ }
230
+ async function rollback(client) {
231
+ try {
232
+ await client.query('ROLLBACK');
233
+ }
234
+ catch {
235
+ // Preserve the original operation error.
236
+ }
156
237
  }
238
+ const UPSERT_SQL = `INSERT INTO byline_search_documents
239
+ (collection_path, document_id, locale, status, zones, title, path, body,
240
+ search_vector, analyzer_fingerprint, facets, filters, updated_at)
241
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::tsvector, $10,
242
+ $11::jsonb, $12::jsonb, $13)
243
+ ON CONFLICT (collection_path, document_id, locale) DO UPDATE SET
244
+ status = EXCLUDED.status,
245
+ zones = EXCLUDED.zones,
246
+ title = EXCLUDED.title,
247
+ path = EXCLUDED.path,
248
+ body = EXCLUDED.body,
249
+ search_vector = EXCLUDED.search_vector,
250
+ analyzer_fingerprint = EXCLUDED.analyzer_fingerprint,
251
+ facets = EXCLUDED.facets,
252
+ filters = EXCLUDED.filters,
253
+ updated_at = EXCLUDED.updated_at`;
@@ -1,10 +1,10 @@
1
1
  -- @byline/search-postgres — 0001_init
2
2
  --
3
- -- The full-text search index, owned entirely by this driver. One row per
4
- -- (collection_path, document_id, locale). The `search_vector` is a weighted
5
- -- tsvector assembled from the type-enriched SearchDocument at upsert time
6
- -- (title => A, body fields => A–D by boost, facet terms => C). Facet ids and
7
- -- filterable scalars are kept as jsonb for aggregation / filtering.
3
+ -- The disposable full-text search index, owned entirely by this driver. One
4
+ -- row per (collection_path, document_id, locale). `search_vector` stores
5
+ -- parser-safe physical tokens produced by @byline/search-analysis and
6
+ -- `analyzer_fingerprint` identifies the exact portable analysis pipeline.
7
+ -- Search indexes created with an older schema must be dropped and rebuilt.
8
8
  --
9
9
  -- Idempotent (IF NOT EXISTS throughout) so re-applying is safe. The driver's
10
10
  -- migration runner records applied versions in byline_search_migrations.
@@ -18,7 +18,8 @@ CREATE TABLE IF NOT EXISTS byline_search_documents (
18
18
  title text NOT NULL DEFAULT '',
19
19
  path text,
20
20
  body text NOT NULL DEFAULT '',
21
- search_vector tsvector,
21
+ search_vector tsvector NOT NULL,
22
+ analyzer_fingerprint text NOT NULL,
22
23
  facets jsonb NOT NULL DEFAULT '{}'::jsonb,
23
24
  filters jsonb NOT NULL DEFAULT '{}'::jsonb,
24
25
  updated_at timestamptz NOT NULL DEFAULT now(),
@@ -40,3 +41,13 @@ CREATE INDEX IF NOT EXISTS byline_search_documents_facets_idx
40
41
  -- Single-collection scoping + status filtering.
41
42
  CREATE INDEX IF NOT EXISTS byline_search_documents_collection_idx
42
43
  ON byline_search_documents (collection_path, status);
44
+
45
+ -- One locked row per collection prevents concurrent processes with different
46
+ -- analyzer fingerprints from mixing incompatible projections. Collection
47
+ -- zones let fingerprint guards retain zone scope without scanning documents.
48
+ CREATE TABLE IF NOT EXISTS byline_search_index_metadata (
49
+ collection_path text PRIMARY KEY,
50
+ analyzer_fingerprint text NOT NULL,
51
+ zones text[] NOT NULL DEFAULT '{}',
52
+ updated_at timestamptz NOT NULL DEFAULT now()
53
+ );
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/search-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "4.8.0",
5
+ "version": "4.9.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -41,7 +41,8 @@
41
41
  "migrations"
42
42
  ],
43
43
  "dependencies": {
44
- "@byline/core": "4.8.0"
44
+ "@byline/core": "4.9.0",
45
+ "@byline/search-analysis": "4.9.0"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "pg": "^8.21.0"
@@ -52,12 +53,14 @@
52
53
  "@types/pg": "^8.20.0",
53
54
  "chokidar": "^5.0.0",
54
55
  "chokidar-cli": "^3.0.0",
56
+ "dotenv": "^17.4.2",
55
57
  "npm-run-all": "^4.1.5",
56
58
  "pg": "^8.22.0",
57
59
  "tsc-alias": "^1.9.1",
58
60
  "tsx": "^4.23.1",
59
61
  "typescript": "^7.0.2",
60
- "vitest": "^4.1.10"
62
+ "vitest": "^4.1.10",
63
+ "@byline/search-conformance": "0.0.2"
61
64
  },
62
65
  "publishConfig": {
63
66
  "access": "public"
@@ -68,6 +71,7 @@
68
71
  "clean": "node scripts/clean.js node_modules dist build .turbo",
69
72
  "lint": "biome check --write --unsafe --diagnostic-level=error",
70
73
  "test": "vitest run --mode=node",
74
+ "test:integration": "vitest run --mode=integration",
71
75
  "test:watch": "vitest --mode=node",
72
76
  "typecheck": "tsc --noEmit"
73
77
  }