@byline/search-postgres 3.15.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.
@@ -0,0 +1,122 @@
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 { describe, expect, it } from 'vitest';
9
+ import { buildIndexRow, weightClass } from './build-index-row.js';
10
+ import { createRegconfigResolver } from './locale-regconfig.js';
11
+ function doc(overrides = {}) {
12
+ return {
13
+ collectionPath: 'publications',
14
+ documentId: 'doc-1',
15
+ locale: 'en',
16
+ status: 'published',
17
+ zones: ['site'],
18
+ title: 'Forest Restoration',
19
+ path: 'forest-restoration',
20
+ updatedAt: '2026-06-01T00:00:00.000Z',
21
+ fields: [],
22
+ ...overrides,
23
+ };
24
+ }
25
+ describe('weightClass', () => {
26
+ it('uses the role default when no boost is set', () => {
27
+ expect(weightClass(undefined, 'B')).toBe('B');
28
+ expect(weightClass(undefined, 'C')).toBe('C');
29
+ });
30
+ it('maps boost magnitude to a weight class', () => {
31
+ expect(weightClass(2, 'B')).toBe('A');
32
+ expect(weightClass(1, 'C')).toBe('B');
33
+ expect(weightClass(0.5, 'B')).toBe('C');
34
+ expect(weightClass(0.1, 'B')).toBe('D');
35
+ });
36
+ });
37
+ describe('buildIndexRow', () => {
38
+ it('does not auto-index the title (display-only; body controls searchability)', () => {
39
+ const row = buildIndexRow(doc());
40
+ expect(row.weighted.A).toBe('');
41
+ expect(row.weighted.B).toBe('');
42
+ expect(row.body).toBe('');
43
+ // title still rides the row for display.
44
+ expect(row.title).toBe('Forest Restoration');
45
+ });
46
+ it('makes the title searchable when listed in body (boosted to A)', () => {
47
+ const row = buildIndexRow(doc({
48
+ fields: [
49
+ { name: 'title', type: 'text', role: 'body', value: 'Forest Restoration', boost: 2 },
50
+ ],
51
+ }));
52
+ expect(row.weighted.A).toBe('Forest Restoration');
53
+ });
54
+ it('places body fields in B by default and respects boost', () => {
55
+ const row = buildIndexRow(doc({
56
+ fields: [
57
+ { name: 'summary', type: 'text', role: 'body', value: 'A short summary.' },
58
+ { name: 'abstract', type: 'text', role: 'body', value: 'Detailed abstract.', boost: 2 },
59
+ ],
60
+ }));
61
+ expect(row.weighted.B).toBe('A short summary.');
62
+ expect(row.weighted.A).toContain('Detailed abstract.');
63
+ });
64
+ it('projects facet ids into facets and folds terms into searchable text (class C)', () => {
65
+ const row = buildIndexRow(doc({
66
+ fields: [
67
+ {
68
+ name: 'topics',
69
+ type: 'facet',
70
+ role: 'facet',
71
+ value: [
72
+ { id: 1, term: 'Ecology' },
73
+ { id: 2, term: 'Biodiversity' },
74
+ ],
75
+ },
76
+ ],
77
+ }));
78
+ expect(row.facets).toEqual({
79
+ topics: [
80
+ { id: 1, term: 'Ecology' },
81
+ { id: 2, term: 'Biodiversity' },
82
+ ],
83
+ });
84
+ expect(row.weighted.C).toBe('Ecology\nBiodiversity');
85
+ });
86
+ it('projects filters and keeps them out of the search text', () => {
87
+ const row = buildIndexRow(doc({
88
+ fields: [
89
+ { name: 'citationCount', type: 'integer', role: 'filter', value: 42 },
90
+ { name: 'publishedYear', type: 'integer', role: 'filter', value: 2026 },
91
+ ],
92
+ }));
93
+ expect(row.filters).toEqual({ citationCount: 42, publishedYear: 2026 });
94
+ expect(row.body).toBe('');
95
+ });
96
+ it('concatenates all weighted text into body for snippets', () => {
97
+ const row = buildIndexRow(doc({
98
+ fields: [
99
+ { name: 'summary', type: 'text', role: 'body', value: 'Body text.' },
100
+ { name: 'topics', type: 'facet', role: 'facet', value: [{ id: 1, term: 'Ecology' }] },
101
+ ],
102
+ }));
103
+ expect(row.body).toBe('Body text.\nEcology');
104
+ });
105
+ });
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,78 @@
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
+ /**
9
+ * `@byline/search-postgres` — the built-in Postgres full-text `SearchProvider`.
10
+ *
11
+ * Reuses the host's existing Postgres connection (no new infrastructure) and
12
+ * owns its own schema (a weighted `tsvector` index table; see `migrate`).
13
+ * Register it on `ServerConfig.search`:
14
+ *
15
+ * ```ts
16
+ * import { pgAdapter } from '@byline/db-postgres'
17
+ * import { postgresSearch } from '@byline/search-postgres'
18
+ *
19
+ * const db = pgAdapter({ connectionString, collections, defaultContentLocale })
20
+ *
21
+ * defineServerConfig({
22
+ * db,
23
+ * // Dev convenience: ensure the search schema at boot. In production,
24
+ * // prefer running `migrate(db.pool)` (or the SQL files) deliberately.
25
+ * search: postgresSearch({ pool: db.pool, autoMigrate: true }),
26
+ * })
27
+ * ```
28
+ */
29
+ import type { SearchProvider } from '@byline/core';
30
+ import type { Pool } from 'pg';
31
+ 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 { type MigrateOptions, type MigrateResult, migrate } from './migrate.js';
34
+ export { PostgresSearchProvider } from './postgres-search-provider.js';
35
+ export interface PostgresSearchOptions {
36
+ /**
37
+ * The host's existing pg connection pool — typically `db.pool` from
38
+ * `pgAdapter`. Reused so the search index lives in the same database with
39
+ * no second connection.
40
+ */
41
+ pool: Pool;
42
+ /**
43
+ * When `true`, ensure the search schema by running pending migrations at
44
+ * construction (idempotent). Defaults to `false` — prefer running
45
+ * `migrate(pool)` (or the SQL files) deliberately in production, per the
46
+ * package README. Convenient for development.
47
+ */
48
+ autoMigrate?: boolean;
49
+ /**
50
+ * Override or extend the locale → Postgres `regconfig` (text-search
51
+ * language) map. Merged over the built-in defaults.
52
+ */
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;
59
+ /**
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).
64
+ */
65
+ defaultLocale?: string;
66
+ /** Optional sink for migration progress lines (e.g. the host logger). */
67
+ log?: (message: string) => void;
68
+ }
69
+ /**
70
+ * Construct the Postgres full-text search provider. Mirrors the established
71
+ * adapter-factory shape (`postgresSearch({ pool })`).
72
+ *
73
+ * Note: `autoMigrate` runs asynchronously and is not awaited here (the
74
+ * factory is synchronous to match the seam). For deterministic startup —
75
+ * especially the first deploy, before any read — call and await
76
+ * `migrate(pool)` explicitly during boot instead.
77
+ */
78
+ export declare function postgresSearch(options: PostgresSearchOptions): SearchProvider;
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
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 { createRegconfigResolver } from './locale-regconfig.js';
9
+ import { migrate } from './migrate.js';
10
+ import { PostgresSearchProvider } from './postgres-search-provider.js';
11
+ export { buildIndexRow, weightClass } from './build-index-row.js';
12
+ export { createRegconfigResolver, DEFAULT_FALLBACK_REGCONFIG, } from './locale-regconfig.js';
13
+ export { migrate } from './migrate.js';
14
+ export { PostgresSearchProvider } from './postgres-search-provider.js';
15
+ /**
16
+ * Construct the Postgres full-text search provider. Mirrors the established
17
+ * adapter-factory shape (`postgresSearch({ pool })`).
18
+ *
19
+ * Note: `autoMigrate` runs asynchronously and is not awaited here (the
20
+ * factory is synchronous to match the seam). For deterministic startup —
21
+ * especially the first deploy, before any read — call and await
22
+ * `migrate(pool)` explicitly during boot instead.
23
+ */
24
+ export function postgresSearch(options) {
25
+ const regconfig = createRegconfigResolver(options.localeRegconfig, options.fallbackRegconfig);
26
+ if (options.autoMigrate === true) {
27
+ void migrate(options.pool, { log: options.log }).catch((error) => {
28
+ const message = `[search-postgres] autoMigrate failed: ${error.message}`;
29
+ if (options.log)
30
+ options.log(message);
31
+ else
32
+ console.error(message);
33
+ });
34
+ }
35
+ return new PostgresSearchProvider(options.pool, regconfig, options.defaultLocale);
36
+ }
@@ -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 const DEFAULT_FALLBACK_REGCONFIG = "simple";
9
+ export type RegconfigResolver = (locale: string | undefined) => string;
10
+ /**
11
+ * Build a locale → regconfig resolver. `overrides` are merged over the
12
+ * built-in map; `fallback` is used for any locale (or locale base) not found.
13
+ */
14
+ export declare function createRegconfigResolver(overrides?: Record<string, string>, fallback?: string): RegconfigResolver;
@@ -0,0 +1,57 @@
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
+ /**
9
+ * Maps a content locale to a Postgres text-search configuration
10
+ * (`regconfig`) — the language used by `to_tsvector` / `websearch_to_tsquery`
11
+ * for stemming and stop-words. The set here is the configs shipped with a
12
+ * stock Postgres install; anything unmapped falls back to `'simple'` (no
13
+ * stemming, no stop-words — correct, just unstemmed).
14
+ *
15
+ * A host can override or extend the map via the `localeRegconfig` factory
16
+ * option, e.g. to wire a custom dictionary or a language Postgres doesn't
17
+ * ship (Thai, etc.).
18
+ */
19
+ const DEFAULT_REGCONFIG = {
20
+ ar: 'arabic',
21
+ da: 'danish',
22
+ de: 'german',
23
+ el: 'greek',
24
+ en: 'english',
25
+ es: 'spanish',
26
+ fi: 'finnish',
27
+ fr: 'french',
28
+ hu: 'hungarian',
29
+ id: 'indonesian',
30
+ it: 'italian',
31
+ lt: 'lithuanian',
32
+ ne: 'nepali',
33
+ nl: 'dutch',
34
+ no: 'norwegian',
35
+ pt: 'portuguese',
36
+ ro: 'romanian',
37
+ ru: 'russian',
38
+ sv: 'swedish',
39
+ ta: 'tamil',
40
+ tr: 'turkish',
41
+ };
42
+ export const DEFAULT_FALLBACK_REGCONFIG = 'simple';
43
+ /**
44
+ * Build a locale → regconfig resolver. `overrides` are merged over the
45
+ * built-in map; `fallback` is used for any locale (or locale base) not found.
46
+ */
47
+ export function createRegconfigResolver(overrides = {}, fallback = DEFAULT_FALLBACK_REGCONFIG) {
48
+ const map = { ...DEFAULT_REGCONFIG, ...overrides };
49
+ return (locale) => {
50
+ if (!locale)
51
+ return fallback;
52
+ if (map[locale] != null)
53
+ return map[locale];
54
+ const base = locale.split('-')[0]?.toLowerCase();
55
+ return (base != null ? map[base] : undefined) ?? fallback;
56
+ };
57
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { Pool } from 'pg';
9
+ export interface MigrateOptions {
10
+ /** Optional sink for progress lines (e.g. the host logger). */
11
+ log?: (message: string) => void;
12
+ }
13
+ export interface MigrateResult {
14
+ /** Versions applied during this run (empty when already up to date). */
15
+ applied: number[];
16
+ }
17
+ /**
18
+ * Apply any pending search-index migrations. Safe to call repeatedly (and at
19
+ * boot via `autoMigrate`) — already-applied versions are skipped.
20
+ */
21
+ export declare function migrate(pool: Pool, options?: MigrateOptions): Promise<MigrateResult>;
@@ -0,0 +1,71 @@
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
+ /**
9
+ * Migration runner — the driver owns its schema. Applies the numbered SQL
10
+ * files in `../migrations` that haven't run yet, recording each in its own
11
+ * `byline_search_migrations` bookkeeping table (separate from the host's
12
+ * migration stream). Idempotent and transactional per file.
13
+ *
14
+ * The numbered `.sql` files are the source of truth: ops can apply them by
15
+ * hand (`psql -f migrations/0001_init.sql`) in locked-down environments, or
16
+ * call `migrate(pool)` / enable `autoMigrate` for convenience.
17
+ */
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ const MIGRATIONS_DIR = fileURLToPath(new URL('../migrations', import.meta.url));
21
+ /**
22
+ * Apply any pending search-index migrations. Safe to call repeatedly (and at
23
+ * boot via `autoMigrate`) — already-applied versions are skipped.
24
+ */
25
+ export async function migrate(pool, options = {}) {
26
+ const log = options.log ?? (() => { });
27
+ await pool.query(`
28
+ CREATE TABLE IF NOT EXISTS byline_search_migrations (
29
+ version integer PRIMARY KEY,
30
+ applied_at timestamptz NOT NULL DEFAULT now()
31
+ )
32
+ `);
33
+ const appliedRows = await pool.query('SELECT version FROM byline_search_migrations');
34
+ const done = new Set(appliedRows.rows.map((r) => Number(r.version)));
35
+ const pending = loadMigrations().filter((m) => !done.has(m.version));
36
+ const applied = [];
37
+ for (const migration of pending) {
38
+ const client = await pool.connect();
39
+ try {
40
+ await client.query('BEGIN');
41
+ await client.query(migration.sql);
42
+ await client.query('INSERT INTO byline_search_migrations (version) VALUES ($1)', [
43
+ migration.version,
44
+ ]);
45
+ await client.query('COMMIT');
46
+ applied.push(migration.version);
47
+ log(`[search-postgres] applied migration ${migration.name}`);
48
+ }
49
+ catch (error) {
50
+ await client.query('ROLLBACK');
51
+ throw new Error(`[search-postgres] migration ${migration.name} failed: ${error.message}`, { cause: error });
52
+ }
53
+ finally {
54
+ client.release();
55
+ }
56
+ }
57
+ return { applied };
58
+ }
59
+ /** Read + parse the numbered `.sql` files, sorted by version ascending. */
60
+ function loadMigrations() {
61
+ return readdirSync(MIGRATIONS_DIR)
62
+ .filter((f) => f.endsWith('.sql'))
63
+ .map((name) => {
64
+ const version = Number.parseInt(name.split('_')[0] ?? '', 10);
65
+ if (!Number.isInteger(version)) {
66
+ throw new Error(`[search-postgres] migration file '${name}' has no leading version number`);
67
+ }
68
+ return { version, name, sql: readFileSync(`${MIGRATIONS_DIR}/${name}`, 'utf8') };
69
+ })
70
+ .sort((a, b) => a.version - b.version);
71
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import type { SearchCapabilities, SearchDocument, SearchProvider, SearchQuery, SearchResults } from '@byline/core';
9
+ import type { Pool } from 'pg';
10
+ import type { RegconfigResolver } from './locale-regconfig.js';
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`).
15
+ */
16
+ export declare class PostgresSearchProvider implements SearchProvider {
17
+ 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?;
26
+ 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);
35
+ upsert(doc: SearchDocument): Promise<void>;
36
+ remove(ref: {
37
+ collectionPath: string;
38
+ documentId: string;
39
+ locale?: string;
40
+ }): Promise<void>;
41
+ reindex(opts?: {
42
+ collectionPath?: string;
43
+ }): Promise<void>;
44
+ search(query: SearchQuery): Promise<SearchResults>;
45
+ }
@@ -0,0 +1,156 @@
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 { buildIndexRow } from './build-index-row.js';
9
+ 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.
12
+ facets: false,
13
+ typoTolerance: false,
14
+ semantic: false,
15
+ bm25: false,
16
+ weighting: true,
17
+ highlights: true,
18
+ };
19
+ /**
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`).
23
+ */
24
+ export class PostgresSearchProvider {
25
+ pool;
26
+ regconfig;
27
+ defaultLocale;
28
+ 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) {
37
+ this.pool = pool;
38
+ this.regconfig = regconfig;
39
+ this.defaultLocale = defaultLocale;
40
+ }
41
+ async upsert(doc) {
42
+ 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
+ ]);
80
+ }
81
+ async remove(ref) {
82
+ if (ref.locale != null) {
83
+ await this.pool.query(`DELETE FROM byline_search_documents
84
+ WHERE collection_path = $1 AND document_id = $2 AND locale = $3`, [ref.collectionPath, ref.documentId, ref.locale]);
85
+ }
86
+ else {
87
+ await this.pool.query(`DELETE FROM byline_search_documents
88
+ WHERE collection_path = $1 AND document_id = $2`, [ref.collectionPath, ref.documentId]);
89
+ }
90
+ }
91
+ 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
+ ]);
98
+ }
99
+ else {
100
+ await this.pool.query('TRUNCATE byline_search_documents');
101
+ }
102
+ }
103
+ 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];
109
+ 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}`);
126
+ }
127
+ const whereSql = where.join(' AND ');
128
+ const cte = `WITH q AS (SELECT websearch_to_tsquery($1::regconfig, $2) AS query)`;
129
+ const countResult = await this.pool.query(`${cte}
130
+ SELECT count(*)::text AS total
131
+ FROM byline_search_documents d, q
132
+ WHERE ${whereSql}`, params);
133
+ const total = Number(countResult.rows[0]?.total ?? 0);
134
+ const limitParam = params.length + 1;
135
+ const offsetParam = params.length + 2;
136
+ 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
141
+ FROM byline_search_documents d, q
142
+ WHERE ${whereSql}
143
+ ORDER BY score DESC, d.updated_at DESC
144
+ 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
+ }));
154
+ return { hits, total };
155
+ }
156
+ }
@@ -0,0 +1,42 @@
1
+ -- @byline/search-postgres — 0001_init
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.
8
+ --
9
+ -- Idempotent (IF NOT EXISTS throughout) so re-applying is safe. The driver's
10
+ -- migration runner records applied versions in byline_search_migrations.
11
+
12
+ CREATE TABLE IF NOT EXISTS byline_search_documents (
13
+ collection_path text NOT NULL,
14
+ document_id text NOT NULL,
15
+ locale text NOT NULL,
16
+ status text NOT NULL,
17
+ zones text[] NOT NULL DEFAULT '{}',
18
+ title text NOT NULL DEFAULT '',
19
+ path text,
20
+ body text NOT NULL DEFAULT '',
21
+ search_vector tsvector,
22
+ facets jsonb NOT NULL DEFAULT '{}'::jsonb,
23
+ filters jsonb NOT NULL DEFAULT '{}'::jsonb,
24
+ updated_at timestamptz NOT NULL DEFAULT now(),
25
+ PRIMARY KEY (collection_path, document_id, locale)
26
+ );
27
+
28
+ -- Ranked full-text search.
29
+ CREATE INDEX IF NOT EXISTS byline_search_documents_vector_idx
30
+ ON byline_search_documents USING gin (search_vector);
31
+
32
+ -- Zone scoping (`zones @> ARRAY[$zone]`).
33
+ CREATE INDEX IF NOT EXISTS byline_search_documents_zones_idx
34
+ ON byline_search_documents USING gin (zones);
35
+
36
+ -- Facet aggregation / filtering over the jsonb projection.
37
+ CREATE INDEX IF NOT EXISTS byline_search_documents_facets_idx
38
+ ON byline_search_documents USING gin (facets jsonb_path_ops);
39
+
40
+ -- Single-collection scoping + status filtering.
41
+ CREATE INDEX IF NOT EXISTS byline_search_documents_collection_idx
42
+ ON byline_search_documents (collection_path, status);